コミットを比較

..

2 コミット

作成者 SHA1 メッセージ 日付
みてるぞ bbf14e3067 #346 2026-05-11 02:41:29 +09:00
みてるぞ d3f2b009bc #346 2026-05-11 02:30:13 +09:00
181個のファイルの変更1275行の追加9085行の削除
+8
ファイルの表示
@@ -1,3 +1,11 @@
---
name: 'Codex task'
about: 'Codex に実装させるための課題'
title: ''
labels:
- codex-ready
---
## 背景 ## 背景
なぜ必要か。 なぜ必要か。
+17 -102
ファイルの表示
@@ -12,21 +12,16 @@ BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
## Stack ## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`. - Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`, - Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`, `factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`, `aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`. - Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS, - Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS, Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and Zustand.
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories ## Main directories
- `backend/app/controllers`: Rails API controllers. - `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models. - `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes. - `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording, - `backend/app/services`: domain services such as version recording, wiki commit, YouTube sync, and similarity calculation.
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes. - `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations. - `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot. - `backend/db/schema.rb`: current schema snapshot.
@@ -89,132 +84,52 @@ cd frontend
npm run dev npm run dev
npm run build npm run build
npm run lint npm run lint
npm run test
npm run test:run
npm run preview npm run preview
``` ```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
`node scripts/generate-sitemap.js`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run. Do not write or report `npm test` as a repository command unless a `test` script is added to `frontend/package.json`.
## Coding style ## Coding style
- Prefer precise, minimal changes. - Prefer precise, minimal changes.
- Do not flatter or over-explain. - Do not flatter or over-explain.
- 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.
- Ruby: never put a space before method-call parentheses. - Ruby: never put a space before method-call parentheses.
- Ruby: never put a line break immediately before `)`.
- Ruby: do not use `%w` or `%i`. - Ruby: do not use `%w` or `%i`.
- Ruby hashes are not blocks; keep `}` on the same line as the final pair. - TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
- Ruby hashes keep the first pair on the same line as `{` unless line length
requires a break.
- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body
indentation.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- TypeScript and Python: use GNU-style spacing before parentheses where
syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text.
- Do not add production dependencies without explicit approval. - Do not add production dependencies without explicit approval.
## Backend rules ## Backend rules
- Inspect existing routes, controllers, models, services, and specs before - Inspect existing routes, controllers, models, services, and specs before editing backend behavior.
editing backend behavior.
- For API behavior changes, add or update request specs under `backend/spec/requests`. - For API behavior changes, add or update request specs under `backend/spec/requests`.
- 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.
- Do not weaken authentication, BAN user checks, or IP BAN checks. - Do not weaken authentication, BAN user checks, or IP BAN checks.
- 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, wiki revisions, and restore/diff behavior.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior. - Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Be sensitive to N+1 queries; avoid introducing them and proactively fix
existing N+1 issues in the code path being edited.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema. - Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules ## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent. - Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; - Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; avoid ad hoc query key arrays.
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`. - Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally. - React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code - Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere.
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions. - Match existing Tailwind, component, and import alias conventions.
### Frontend TSX style
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- A leading tab is exactly equivalent to 8 leading spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
Preferred:
```tsx
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
return (
<TextArea
{...rest}
ref={ref}
value={tags}
invalid={errors && errors.length > 0}
onChange={ev => setTags (ev.target.value)}/>)
}
```
Avoid:
```tsx
function PostFormTagsArea ({ tags, setTags }: Props) {
return (
<TextArea
value={tags}
onChange={ev => setTags (ev.target.value)}
/>
)
}
```
## Codex workflow ## Codex workflow
- First inspect existing patterns; do not invent new architecture when a local convention exists. - First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue. - Keep changes scoped to the requested issue.
- 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 behavior, inspect the related request specs and service objects.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication - If frontend code changes, run the existing frontend verification commands that apply: `npm run build` and `npm run lint`.
behavior, inspect the related request specs and service objects. - If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- If frontend code changes, run the existing frontend verification commands
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend
changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure. - If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria ## Completion criteria
+25 -90
ファイルの表示
@@ -4,9 +4,7 @@
These rules apply to work under `backend/`. These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs, This is a Rails API app using Active Record, RSpec, request specs, service objects, representation classes, and version tables for post/tag/wiki history.
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands ## Commands
@@ -52,57 +50,32 @@ If a command cannot be run or fails, report the exact command and failure.
- `app/controllers`: API controllers. - `app/controllers`: API controllers.
- `app/models`: Active Record models and concerns. - `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping. - `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit, - `app/services`: domain services such as version recorders, wiki commit, YouTube sync, and similarity calculation.
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes. - `config/routes.rb`: public API routes.
- `db/migrate`: migrations. - `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot. - `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks. - `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests. - `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model, Before changing behavior, inspect the matching route, controller, model, service, representation, and spec.
service, representation, and spec.
## Ruby style ## Ruby style
- Prefer precise, minimal changes. - Prefer precise, minimal changes.
- Use single quotes unless interpolation or escaping makes double quotes better. - Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses. - Do not put a space before Ruby method-call parentheses.
- Never put a line break immediately before `)` in Ruby.
- Do not use `%w` or `%i` in new Ruby code. - Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it.
- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate
rules.
- Do not format Ruby hashes like Ruby blocks.
- For Ruby hashes, keep the closing `}` on the same line as the final pair.
- Keep the first pair on the same line as `{` by default.
- If the hash would exceed the line limit, break after `{` and indent pairs
by 4 spaces.
- Put one logical pair per line when the expression would otherwise become
dense.
- For Ruby arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- For Ruby blocks, use 2-space indentation for the block body.
- Keep comments short and useful; avoid narrating obvious code. - Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval. - Do not add production dependencies without approval.
## Authentication and authorization ## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in - Authentication is handled through the `X-Transfer-Code` header in `ApplicationController#authenticate_user`.
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`. - `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task - Do not bypass or weaken the `X-Transfer-Code` flow unless the task explicitly changes authentication.
explicitly changes authentication. - Unauthenticated write actions should return `:unauthorized` consistently with existing controllers.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`. - Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where - Use `current_user.gte_member?` for member-or-admin write permissions where existing controllers do so.
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes. - Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks. - Do not replace role checks with looser presence checks.
@@ -115,8 +88,7 @@ service, representation, and spec.
- User and IP bans use `banned_at`, not a boolean `banned` column. - User and IP bans use `banned_at`, not a boolean `banned` column.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`. - `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior. - Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or - If changing request authentication or controller before actions, add or update request specs covering banned users and banned IP addresses.
update request specs covering banned users and banned IP addresses.
## RSpec ## RSpec
@@ -127,86 +99,49 @@ service, representation, and spec.
- Put Rake task coverage under `spec/tasks`. - Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`. - `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`. - Request specs include `AuthHelper` and `JsonHelper`.
- `AuthHelper#sign_in_as(user)` stubs - `AuthHelper#sign_in_as(user)` stubs `ApplicationController#current_user`; use it when matching existing request spec style.
`ApplicationController#current_user`; use it when matching existing - Add or update request specs for API behavior changes, especially status codes, permissions, response shape, and version conflict behavior.
request spec style.
- Add or update request specs for API behavior changes, especially status
codes, permissions, response shape, and version conflict behavior.
## Migrations ## Migrations
- Keep migrations and `db/schema.rb` consistent. - Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`. - Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of - For data backfills inside migrations, follow the existing pattern of defining migration-local `ActiveRecord::Base` classes with `self.table_name`.
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints. - Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`. - Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless - Do not edit old migrations just to change current behavior unless explicitly requested; add a new migration.
explicitly requested; add a new migration.
## Version tables ## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages. - Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive - Current records have `version_no`; version tables have positive `version_no` with unique indexes scoped to the parent record.
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`. - Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern. - Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version - Use the existing recorder services instead of manually inserting version rows in application code:
rows in application code:
- `PostVersionRecorder` - `PostVersionRecorder`
- `TagVersionRecorder` - `TagVersionRecorder`
- `NicoTagVersionRecorder` - `NicoTagVersionRecorder`
- `WikiVersionRecorder` - `WikiVersionRecorder`
- `TagVersioning` - `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency, - `VersionRecorder` locks the current record, validates sequence consistency, skips unchanged update snapshots, creates the next version row, and updates the record `version_no`.
skips unchanged update snapshots, creates the next version row, and updates
the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created. - Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and - For optimistic concurrency paths, preserve `base_version_no`, `force`, and `merge` semantics and cover conflicts in request specs.
`merge` semantics and cover conflicts in request specs.
## Domain cautions ## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges, - Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior.
viewed state, and version conflict behavior. - Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots.
- Tags have canonical names, aliases through `TagName`, categories, parent - Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code.
implications, discard behavior, and version snapshots. - Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths.
- Nico tags have separate relation/version behavior; do not treat them like - Materials, theatres, and comments have user and permission checks; inspect the controller before changing them.
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses ## API responses
- Use representation classes under `app/representations` when existing endpoints do. - Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts. - Keep response keys consistent with existing JSON contracts; frontend code expects camelCase conversion client-side, while Rails params and JSON keys are generally snake_case.
- Frontend code expects camelCase conversion client-side, while Rails params - Preserve existing HTTP status conventions: `:unauthorized` for no user, `:forbidden` for insufficient role or banned user, `:not_found` for missing records, and `:unprocessable_entity` for validation failures.
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- Be sensitive to N+1 queries in all backend work.
- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when
you find them in the code path you are editing.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work ## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency - Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed.
directories unless explicitly needed. - Do not modify generated schema or migration output without the corresponding migration when schema changes are made.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
-2
ファイルの表示
@@ -69,5 +69,3 @@ gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false gem 'aws-sdk-s3', require: false
gem 'rails-i18n', '~> 8.0.0'
-4
ファイルの表示
@@ -306,9 +306,6 @@ GEM
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.2)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.2)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.0.2) railties (8.0.2)
actionpack (= 8.0.2) actionpack (= 8.0.2)
activesupport (= 8.0.2) activesupport (= 8.0.2)
@@ -480,7 +477,6 @@ DEPENDENCIES
puma (>= 5.0) puma (>= 5.0)
rack-cors rack-cors
rails (~> 8.0.2) rails (~> 8.0.2)
rails-i18n (~> 8.0.0)
rspec-rails (~> 8.0) rspec-rails (~> 8.0)
rubocop-rails-omakase rubocop-rails-omakase
sprockets-rails sprockets-rails
-48
ファイルの表示
@@ -1,7 +1,4 @@
class ApplicationController < ActionController::API class ApplicationController < ActionController::API
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
before_action :reject_banned_ip_address! before_action :reject_banned_ip_address!
before_action :authenticate_user before_action :authenticate_user
before_action :reject_banned_user! before_action :reject_banned_user!
@@ -28,27 +25,6 @@ class ApplicationController < ActionController::API
end end
end end
def render_bad_request message = 'リクエストが不正です.'
render json: { type: 'bad_request',
message:,
errors: { },
base_errors: [message] },
status: :bad_request
end
def render_unprocessable_entity message = '入力を確認してください.', field: nil
render_validation_error(fields: field ? { field => [message] } : { },
base: field ? [] : [message])
end
def render_record_invalid error
render_validation_error error.record
end
def render_record_not_unique _error = nil
render_validation_error base: ['すでに存在してゐます.']
end
def reject_banned_ip_address! def reject_banned_ip_address!
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton) ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
return unless ip_address&.banned? return unless ip_address&.banned?
@@ -61,28 +37,4 @@ class ApplicationController < ActionController::API
head :forbidden head :forbidden
end end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
errors = { }
if record
record.errors.each do |error|
errors[error.attribute] ||= []
errors[error.attribute] << error.message
end
end
fields.each do |attr, messages|
errors[attr.to_sym] ||= []
errors[attr.to_sym].concat(Array(messages))
end
base_errors = Array(base) + Array(errors.delete(:base))
render json: { type: 'validation_error',
message: '入力内容を確認してください.',
errors:,
base_errors: },
status:
end
end end
+3 -7
ファイルの表示
@@ -2,8 +2,7 @@ class DeerjikistsController < ApplicationController
def show def show
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank?
return render_bad_request('code は必須です.') if code.blank?
deerjikist = Deerjikist deerjikist = Deerjikist
.joins(:tag) .joins(:tag)
@@ -23,9 +22,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
tag_id = params[:tag_id].to_i tag_id = params[:tag_id].to_i
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank? || tag_id <= 0
return render_bad_request('code は必須です.') if code.blank?
return render_bad_request('tag_id が不正です.') if tag_id <= 0
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d| deerjikist = Deerjikist.find_or_initialize_by(platform:, code:).tap do |d|
d.tag_id = tag_id d.tag_id = tag_id
@@ -41,8 +38,7 @@ class DeerjikistsController < ApplicationController
platform = params[:platform].to_s.strip platform = params[:platform].to_s.strip
code = params[:code].to_s.strip code = params[:code].to_s.strip
return render_bad_request('platform は必須です.') if platform.blank? return head :bad_request if platform.blank? || code.blank?
return render_bad_request('code は必須です.') if code.blank?
Deerjikist.find([platform, code]).destroy! Deerjikist.find([platform, code]).destroy!
+4 -12
ファイルの表示
@@ -40,11 +40,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -58,7 +54,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created render json: MaterialRepr.base(material, host: request.base_url), status: :created
else else
render_validation_error material render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end end
end end
@@ -72,11 +68,7 @@ class MaterialsController < ApplicationController
tag_name_raw = params[:tag].to_s.strip tag_name_raw = params[:tag].to_s.strip
file = params[:file] file = params[:file]
url = params[:url].to_s.strip.presence url = params[:url].to_s.strip.presence
return render_unprocessable_entity('タグは必須です.', field: :tag) if tag_name_raw.blank? return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
if file.blank? && url.blank?
return render_validation_error fields: { file: ['ファイルまたは URL は必須です.'],
url: ['ファイルまたは URL は必須です.'] }
end
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw) tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag tag = tag_name.tag
@@ -92,7 +84,7 @@ class MaterialsController < ApplicationController
if material.save if material.save
render json: MaterialRepr.base(material, host: request.base_url) render json: MaterialRepr.base(material, host: request.base_url)
else else
render_validation_error material render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end end
end end
+19 -82
ファイルの表示
@@ -1,69 +1,26 @@
class NicoTagsController < ApplicationController class NicoTagsController < ApplicationController
def index def index
name = params[:name].presence limit = (params[:limit] || 20).to_i
linked_tag = params[:linked_tag].presence cursor = params[:cursor].presence
link_status = params[:link_status].presence
order = params[:order].to_s.split(':', 2).map(&:strip)
order[0] = 'updated_at' unless order[0].in?(['name', 'created_at', 'updated_at'])
unless order[1].in?(['asc', 'desc'])
order[1] = order[0] == 'name' ? 'asc' : 'desc'
end
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
post_tag_max_sql =
PostTag
.select('tag_id, MAX(created_at) AS max_created_at')
.group('tag_id')
.to_sql
q = Tag.nico_tags q = Tag.nico_tags
.joins(:tag_name)
.joins("LEFT JOIN (#{ post_tag_max_sql }) post_tag_max " \
'ON post_tag_max.tag_id = tags.id')
.includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page })
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name .order(updated_at: :desc)
if linked_tag q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor
linked_tag_ids =
Tag
.joins(:tag_name)
.where('tag_names.name LIKE ?', "%#{ linked_tag }%")
.pluck(:id)
linked_nico_tag_ids = NicoTagRelation.where(tag_id: linked_tag_ids).pluck(:nico_tag_id)
q = q.where(id: linked_nico_tag_ids)
end
if link_status.in?(['linked', 'unlinked'])
exists_sql =
'EXISTS (SELECT 1 FROM nico_tag_relations ' \
'WHERE nico_tag_relations.nico_tag_id = tags.id)'
q = link_status == 'linked' ? q.where(exists_sql) : q.where("NOT #{ exists_sql }")
end
count = q.count tags = q.limit(limit + 1).to_a
sort_sql =
case order[0] next_cursor = nil
when 'name' if tags.size > limit
'tag_names.name' next_cursor = tags.last.updated_at.iso8601(6)
when 'updated_at' tags = tags.first(limit)
'post_tag_max.max_created_at' end
else
"tags.#{ order[0] }"
end
tags = q.reselect('tags.*',
Arel.sql('post_tag_max.max_created_at AS recent_post_tag_created_at'))
.order(Arel.sql("#{ sort_sql } #{ order[1] }, tags.id #{ order[1] }"))
.limit(limit)
.offset((page - 1) * limit)
.to_a
render json: { tags: tags.map { |tag| render json: { tags: tags.map { |tag|
TagRepr.base(tag).merge( TagRepr.base(tag).merge(linked_tags: tag.linked_tags.map { |lt|
recent_post_tag_created_at: tag.recent_post_tag_created_at, TagRepr.base(lt)
linked_tags: tag.linked_tags.map { |lt| TagRepr.base(lt) }) })
}, count: } }, next_cursor: }
end end
def update def update
@@ -73,18 +30,14 @@ class NicoTagsController < ApplicationController
id = params[:id].to_i id = params[:id].to_i
tag = Tag.find(id) tag = Tag.find(id)
return render_bad_request('ニコニコ・タグを指定してください.') unless tag.nico? return head :bad_request unless tag.nico?
linked_tag_names = params[:tags].to_s.split linked_tag_names = params[:tags].to_s.split
linked_tags = nil linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
return head :bad_request if linked_tags.any? { |t| t.nico? }
ApplicationRecord.transaction do ApplicationRecord.transaction do
linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false,
with_no_deerjikist: false)
if linked_tags.any? { |t| t.nico? }
raise Tag::NicoTagNormalisationError
end
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
tag.linked_tags = linked_tags tag.linked_tags = linked_tags
@@ -94,21 +47,5 @@ class NicoTagsController < ApplicationController
end end
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: ['ニコニコ・タグ同士は連携できません.'] }
rescue ActiveRecord::RecordInvalid => e
render_nico_tag_form_record_invalid e.record
end
private
def render_nico_tag_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end end
end end
+18 -59
ファイルの表示
@@ -44,8 +44,7 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.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(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
tags: [: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
@@ -96,9 +95,7 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(:uploaded_user, :parents, :children, post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -107,25 +104,12 @@ class PostsController < ApplicationController
end end
def show def show
post = post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
Post
.includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail
.find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
parent_posts = post.parents.with_attached_thumbnail.order(:id).to_a render json: PostRepr.base(post, current_user)
child_posts = post.children.with_attached_thumbnail.order(:id).to_a .merge(tags: build_tag_tree_for(post.tags),
sibling_posts = sibling_posts_by_parent(parent_posts.map(&:id)) related: PostRepr.many(post.related(limit: 20)))
related = post.related(limit: 20).to_a
render json: PostRepr.detail(post, current_user,
parent_posts:,
child_posts:,
sibling_posts:,
related:)
.merge(tags: build_tag_tree_for(post.tags))
end end
def create def create
@@ -164,11 +148,11 @@ class PostsController < ApplicationController
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' } head :bad_request
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end end
def viewed def viewed
@@ -191,10 +175,10 @@ class PostsController < ApplicationController
force = bool?(:force) force = bool?(:force)
merge = bool?(:merge) merge = bool?(:merge)
return render_bad_request('force と merge は同時に指定できません.') if force && merge return head :bad_request if force && merge
base_version_no = parse_base_version_no base_version_no = parse_base_version_no
return render_bad_request('base_version_no は必須です.') if !(force) && !(base_version_no) return head :bad_request if !(force) && !(base_version_no)
title = params[:title].presence title = params[:title].presence
tag_names = params[:tags].to_s.split tag_names = params[:tags].to_s.split
@@ -254,11 +238,11 @@ class PostsController < ApplicationController
json['tags'] = build_tag_tree_for(post.tags) 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: ['ニコニコ・タグは直接指定できません.'] } head :bad_request
rescue ArgumentError => e rescue ArgumentError => e
render_validation_error fields: { parent_post_ids: [e.message] } render json: { errors: [e.message] }, status: :unprocessable_entity
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
render_post_form_record_invalid e.record render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end end
def changes def changes
@@ -401,7 +385,7 @@ class PostsController < ApplicationController
return nil unless tag return nil unless tag
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.inline(tag).merge(children: []) return TagRepr.base(tag).merge(children: [])
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -413,26 +397,12 @@ 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:) memo[tag_id] = TagRepr.base(tag).merge(children:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
end end
def sibling_posts_by_parent parent_post_ids
return { } if parent_post_ids.blank?
implications =
PostImplication
.where(parent_post_id: parent_post_ids)
.includes(post: { thumbnail_attachment: :blob })
.order(:parent_post_id, :post_id)
implications.group_by(&:parent_post_id).transform_values { |items|
items.map(&:post)
}
end
def parse_parent_post_ids def parse_parent_post_ids
raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids) raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids)
@@ -446,7 +416,7 @@ class PostsController < ApplicationController
def sync_parent_posts! post, parent_post_ids def sync_parent_posts! post, parent_post_ids
if parent_post_ids.include?(post.id) if parent_post_ids.include?(post.id)
post.errors.add :parent_post_ids, '自分自身を親投稿にはできません.' post.errors.add(:base, '自分自身を親投稿にはできません.')
raise ActiveRecord::RecordInvalid, post raise ActiveRecord::RecordInvalid, post
end end
@@ -454,8 +424,7 @@ class PostsController < ApplicationController
missing_ids = parent_post_ids - existing_ids missing_ids = parent_post_ids - existing_ids
if missing_ids.present? if missing_ids.present?
post.errors.add :parent_post_ids, post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }")
"存在しない親投稿 Id. があります: #{ missing_ids.join(' ') }"
raise ActiveRecord::RecordInvalid, post raise ActiveRecord::RecordInvalid, post
end end
@@ -671,14 +640,4 @@ class PostsController < ApplicationController
merged.uniq.sort merged.uniq.sort
end end
def render_post_form_record_invalid record
if record.is_a?(TagName) || record.is_a?(Tag)
render_validation_error fields: { tags: record.errors.full_messages.map { |message|
"タグ名 “#{ record.name }”: #{ message }"
} }
else
render_validation_error record
end
end
end end
+4 -8
ファイルの表示
@@ -4,7 +4,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.') unless url.present? return head :bad_request unless url.present?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -16,7 +16,7 @@ class PreviewController < ApplicationController
render json: { title: title } render json: { title: title }
rescue => e rescue => e
render_bad_request(e.message) render json: { error: e.message }, status: :bad_request
end end
def thumbnail def thumbnail
@@ -25,7 +25,7 @@ class PreviewController < ApplicationController
return head :unauthorized unless current_user return head :unauthorized unless current_user
url = params[:url] url = params[:url]
return render_bad_request('URL は必須です.') if url.blank? return head :bad_request if url.blank?
unless url.start_with?(/http(s)?:\/\//) unless url.start_with?(/http(s)?:\/\//)
url = 'http://' + url url = 'http://' + url
@@ -40,11 +40,7 @@ class PreviewController < ApplicationController
File.delete(path) rescue nil File.delete(path) rescue nil
send_file image.path, type: 'image/png', disposition: 'inline' send_file image.path, type: 'image/png', disposition: 'inline'
else else
render json: { type: 'internal_server_error', render json: { error: 'Failed to generate thumbnail' }, status: :internal_server_error
message: 'サムネールを生成できませんでした.',
errors: { },
base_errors: ['サムネールを生成できませんでした.'] },
status: :internal_server_error
end end
end end
end end
+4 -6
ファイルの表示
@@ -5,12 +5,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
@@ -28,12 +27,11 @@ class TagChildrenController < ApplicationController
parent_id = params[:parent_id] parent_id = params[:parent_id]
child_id = params[:child_id] child_id = params[:child_id]
return render_bad_request('parent_id は必須です.') if parent_id.blank? return head :bad_request if parent_id.blank? || child_id.blank?
return render_bad_request('child_id は必須です.') if child_id.blank?
parent = Tag.find(parent_id) parent = Tag.find(parent_id)
child = Tag.find(child_id) child = Tag.find(child_id)
return render_bad_request('ニコニコ・タグの階層は変更できません.') if parent.nico? || child.nico? return head :bad_request if parent.nico? || child.nico?
ApplicationRecord.transaction do ApplicationRecord.transaction do
TagVersioning.ensure_snapshot!(child, created_by_user: current_user) TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
+14 -34
ファイルの表示
@@ -168,7 +168,7 @@ class TagsController < ApplicationController
def show_by_name def show_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -192,7 +192,7 @@ class TagsController < ApplicationController
def deerjikists_by_name def deerjikists_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
@@ -214,24 +214,21 @@ class TagsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
tag.deerjikists = [] tag.deerjikists = []
params[:_json].each.with_index do |item, i| params[:_json].each do
platform = item[:platform] platform = _1[:platform]
code = normalise_deerjikist_code(platform, item[:code]) code = normalise_deerjikist_code(platform, _1[:code])
deerjikist = Deerjikist.find_or_initialize_by(platform:, code:) deerjikist = Deerjikist.find_or_initialize_by(platform:, code:)
deerjikist.tag = tag deerjikist.tag = tag
render_deerjikist_form_record_invalid(deerjikist, i) unless deerjikist.save deerjikist.save!
raise ActiveRecord::Rollback if performed?
end end
end end
return if performed?
render json: DeerjikistRepr.many(tag.reload.deerjikists) render json: DeerjikistRepr.many(tag.reload.deerjikists)
end end
def materials_by_name def materials_by_name
name = params[:name].to_s.strip name = params[:name].to_s.strip
return render_bad_request('name は必須です.') if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, :materials, tag_name: :wiki_page)
@@ -250,16 +247,17 @@ class TagsController < ApplicationController
name = params[:name].to_s.strip name = params[:name].to_s.strip
category = params[:category].to_s.strip category = params[:category].to_s.strip
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return head :unprocessable_entity if name.blank? || category.blank?
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
if name != tag.name && if name != tag.name &&
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]) tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
return render_unprocessable_entity('システム・タグの名称は変更できません.', field: :name) return render json: { error: 'システム・タグの名称は変更できません.' },
status: :unprocessable_entity
end end
if tag.nico? || category == 'nico' if tag.nico? || category == 'nico'
return render_unprocessable_entity('ニコタグは変更できません.', field: :category) return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end end
alias_names = params[:aliases].to_s.split.uniq alias_names = params[:aliases].to_s.split.uniq
@@ -304,7 +302,8 @@ class TagsController < ApplicationController
tag = Tag.find(params[:id]) tag = Tag.find(params[:id])
if tag.nico? || (category.present? && category == 'nico') if tag.nico? || (category.present? && category == 'nico')
return render_unprocessable_entity('ニコタグは変更できません.', field: :category) return render json: { error: 'ニコタグは変更できません.' },
status: :unprocessable_entity
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -438,23 +437,4 @@ class TagsController < ApplicationController
rescue rescue
nil nil
end end
def render_deerjikist_form_record_invalid deerjikist, index
fields = { }
deerjikist.errors.each do |error|
field =
case error.attribute
when :platform, :code
"deerjikists.#{ index }.#{ error.attribute }"
else
:deerjikists
end
fields[field] ||= []
fields[field] << error.full_message
end
render_validation_error fields:
end
end end
+3 -25
ファイルの表示
@@ -1,28 +1,21 @@
class TheatreCommentsController < ApplicationController class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt < 0 no_gt = 0 if no_gt.negative?
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.order(no: :desc) .order(no: :desc)
.limit(limit)
render json: comments.map { render json: comments.as_json(include: { user: { only: [:id, :name] } })
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end end
def create def create
return head :unauthorized unless current_user return head :unauthorized unless current_user
content = params[:content] content = params[:content]
return render_unprocessable_entity('本文は必須です.', field: :content) if content.blank? return head :unprocessable_entity if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id]) theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre return head :not_found unless theatre
@@ -36,19 +29,4 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created render json: comment, status: :created
end end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
return head :forbidden unless comment.user == current_user
comment.discard!
head :no_content
end
end end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreProgrammesController < ApplicationController
def index
limit = params[:limit].to_i
limit = 100 if limit <= 0
position_gt = params[:position_gt].to_i
position_gt = 0 if position_gt < 0
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(:post)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.map { |programme|
programme.as_json.merge(post: { id: programme.post.id,
title: programme.post.title,
url: programme.post.url })
}
end
end
-22
ファイルの表示
@@ -1,22 +0,0 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:post, tags: :tag_name)
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: { id: event.post.id, title: event.post.title, url: event.post.url },
tags: event.tags.map { |tag| { id: tag.id, name: tag.name } },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+8 -113
ファイルの表示
@@ -31,7 +31,9 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:) render json: {
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end end
def next_post def next_post
@@ -41,119 +43,12 @@ class TheatresController < ApplicationController
return head :not_found unless theatre return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do post = Post.where("url LIKE '%nicovideo.jp%'")
theatre.lock! .or(Post.where("url LIKE '%youtube.com%'"))
TheatrePostAdvancer.call(theatre:) .order('RAND()')
end .first
theatre.update!(current_post: post, current_post_started_at: Time.current)
head :no_content head :no_content
end end
def skip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
skipped = false
conflicted = false
ApplicationRecord.transaction do
theatre.lock!
if theatre.current_post
TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap {
_1.expires_at = 30.seconds.from_now
}.save!
if theatre.current_post_id != requested_post_id
conflicted = true
next
end
TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user)
vote_status = skip_vote_status(theatre)
if vote_status[:votes_count] >= vote_status[:required_count]
TheatreSkipFinalizer.call(theatre:, user: current_user)
TheatrePostAdvancer.call(theatre:)
skipped = true
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped:)
end
def unskip_vote
return head :unauthorized unless current_user
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
conflicted = false
theatre.with_lock do
if theatre.current_post
if theatre.current_post_id != requested_post_id
conflicted = true
else
TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all
end
end
end
theatre.reload
return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end end
+2 -2
ファイルの表示
@@ -42,12 +42,12 @@ class UsersController < ApplicationController
return head :unauthorized if user&.id != params[:id].to_i return head :unauthorized if user&.id != params[:id].to_i
name = params[:name] name = params[:name]
return render_unprocessable_entity('名前は必須です.', field: :name) if name.blank? return head :bad_request if name.blank?
if user.update(name:) if user.update(name:)
render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok render json: user.slice(:id, :name, :inheritance_code, :role), status: :ok
else else
render_validation_error user render json: user.errors, status: :unprocessable_entity
end end
end end
+6 -10
ファイルの表示
@@ -46,7 +46,7 @@ class WikiPagesController < ApplicationController
def diff def diff
id = params[:id] id = params[:id]
return render_bad_request('id は必須です.') if id.blank? return head :bad_request if id.blank?
from = params[:from].presence from = params[:from].presence
to = params[:to].presence to = params[:to].presence
@@ -56,7 +56,7 @@ class WikiPagesController < ApplicationController
from_rev = from && page.wiki_revisions.find(from) from_rev = from && page.wiki_revisions.find(from)
to_rev = to ? page.wiki_revisions.find(to) : page.current_revision to_rev = to ? page.wiki_revisions.find(to) : page.current_revision
if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?)) if ((from_rev && !(from_rev.content?)) || !(to_rev&.content?))
return render_unprocessable_entity('差分を表示できない版です.') return head :unprocessable_entity
end end
diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines) diffs = Diff::LCS.sdiff(from_rev&.body&.lines || [], to_rev.body.lines)
@@ -89,8 +89,7 @@ class WikiPagesController < ApplicationController
body = params[:body].to_s body = params[:body].to_s
message = params[:message].presence message = params[:message].presence
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return head :unprocessable_entity if title.blank? || body.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
tag_name = TagName.find_undiscard_or_create_by!(name: title) tag_name = TagName.find_undiscard_or_create_by!(name: title)
@@ -102,10 +101,8 @@ class WikiPagesController < ApplicationController
message:) message:)
render json: WikiPageRepr.base(page), status: :created render json: WikiPageRepr.base(page), status: :created
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
render_validation_error e.record head :unprocessable_entity
rescue ActiveRecord::RecordNotUnique
render_record_not_unique
end end
def update def update
@@ -115,8 +112,7 @@ class WikiPagesController < ApplicationController
title = params[:title]&.strip title = params[:title]&.strip
body = params[:body].to_s body = params[:body].to_s
return render_unprocessable_entity('タイトルは必須です.', field: :title) if title.blank? return head :unprocessable_entity if title.blank? || body.blank?
return render_unprocessable_entity('本文は必須です.', field: :body) if body.blank?
page = WikiPage.find(params[:id]) page = WikiPage.find(params[:id])
base_revision_id = params[:base_revision_id].presence base_revision_id = params[:base_revision_id].presence
+1 -1
ファイルの表示
@@ -94,7 +94,7 @@ class Post < ApplicationRecord
return if !(f) || !(b) return if !(f) || !(b)
if f >= b if f >= b
errors.add :original_created_at, 'オリジナルの作成日時の順番がをかしぃです.' errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
end end
end end
+1 -1
ファイルの表示
@@ -81,7 +81,7 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id def material_id = materials.first&.id
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists? def has_deerjikists = deerjikists.present?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
-4
ファイルの表示
@@ -7,10 +7,6 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all
has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all
belongs_to :host_user, class_name: 'User', optional: true belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User' belongs_to :created_by_user, class_name: 'User'
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
-10
ファイルの表示
@@ -1,10 +0,0 @@
class TheatreSkipEvent < ApplicationRecord
belongs_to :theatre
belongs_to :post
belongs_to :skipped_by_user, class_name: 'User'
has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all
has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all
has_many :users, through: :voters
has_many :tags, through: :event_tags
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
-6
ファイルの表示
@@ -1,6 +0,0 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
-7
ファイルの表示
@@ -1,7 +0,0 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+6 -51
ファイルの表示
@@ -2,65 +2,20 @@
module PostRepr module PostRepr
BASE_FIELDS = [ BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE },
:id, methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze
:version_no,
:url,
:title,
:thumbnail_base,
:original_created_from,
:original_created_before,
:created_at,
:updated_at
].freeze
module_function module_function
def base post, current_user = nil def base post, current_user = nil
json = common(post) json = post.as_json(BASE)
json['tags'] = tag_json(post.tags) return json.merge(viewed: false) unless current_user
json['uploaded_user'] = post.uploaded_user && UserRepr.base(post.uploaded_user)
json['viewed'] = current_user ? current_user.viewed?(post) : false
json
end
def detail post, current_user = nil, parent_posts: [], child_posts: [], viewed = current_user.viewed?(post)
sibling_posts: { }, related: [] json.merge(viewed:)
base(post, current_user).merge(
'parent_posts' => cards(parent_posts),
'child_posts' => cards(child_posts),
'sibling_posts' => sibling_posts.transform_keys(&:to_s).transform_values { |posts|
cards(posts)
},
'related' => cards(related))
end
def card post
common(post).merge('parent_posts' => [], 'child_posts' => [])
end
def cards posts
posts.map { |post| card(post) }
end end
def many posts, current_user = nil def many posts, current_user = nil
posts.map { |p| base(p, current_user) } posts.map { |p| base(p, current_user) }
end end
def common post
BASE_FIELDS.to_h { |field| [field.to_s, post.public_send(field)] }
.merge('thumbnail' => thumbnail_url(post))
end
def tag_json tags
tags.map { |tag| TagRepr.inline(tag) }
end
def thumbnail_url post
return nil unless post.thumbnail.attached?
Rails.application.routes.url_helpers.rails_blob_url(post.thumbnail, only_path: false)
rescue
nil
end
end end
-4
ファイルの表示
@@ -12,9 +12,5 @@ module TagRepr
parents: tag.parents.map { _1.as_json(BASE) }) parents: tag.parents.map { _1.as_json(BASE) })
end end
def inline tag
tag.as_json(BASE).merge(aliases: [], parents: [])
end
def many(tags) = tags.map { |t| base(t) } def many(tags) = tags.map { |t| base(t) }
end end
-29
ファイルの表示
@@ -1,29 +0,0 @@
class TheatrePostAdvancer
def self.call(theatre:)
new(theatre:).call
end
def initialize(theatre:)
@theatre = theatre
end
def call
previous_post = theatre.current_post
post = TheatrePostSelector.new(theatre:).select
TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post
theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil)
if post
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
post
end
private
attr_reader :theatre
end
-119
ファイルの表示
@@ -1,119 +0,0 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
ELIGIBLE_POST_URL_CONDITION =
["url LIKE '%nicovideo.jp%'",
"url LIKE '%youtube.com/watch%'",
"url LIKE '%youtu.be/%'"]
.join(' OR ')
def initialize theatre:
@theatre = theatre
end
def select
candidates = weighted_candidates
return nil if candidates.empty?
total = candidates.sum(&:weight)
target = rand * total
candidates.each do |candidate|
target -= candidate.weight
return candidate.post if target <= 0
end
candidates.last.post
end
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
end
private
attr_reader :theatre
def weighted_candidates
@weighted_candidates ||= begin
penalties = tag_penalties
posts = eligible_posts.includes(tags: :tag_name).to_a
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty))
end
end
end
def eligible_posts
posts = Post.where(ELIGIBLE_POST_URL_CONDITION)
posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id
posts
end
def active_user_ids
@active_user_ids ||= theatre.watching_users.ids
end
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: light_tag_json(tag),
penalty: }
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json candidates
candidates.map { |candidate|
{ post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| light_tag_json(tag) } }
}
end
def light_post_json post
{ id: post.id,
title: post.title,
url: post.url }
end
def light_tag_json tag
{ id: tag.id,
name: tag.name,
category: tag.category }
end
end
-40
ファイルの表示
@@ -1,40 +0,0 @@
class TheatreSkipFinalizer
def self.call(theatre:, user:)
new(theatre:, user:).call
end
def initialize(theatre:, user:)
@theatre = theatre
@user = user
end
def call
return unless theatre.current_post
post = theatre.current_post
voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user)
return if voters.empty?
event = TheatreSkipEvent.create!(
theatre:,
post:,
skipped_by_user: user,
programme_position: theatre.programmes.maximum(:position))
voters.uniq(&:id).each do |voter|
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter)
end
post.tags.find_each do |tag|
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
end
TheatreSkipVote.where(theatre:, post:).delete_all
event
end
private
attr_reader :theatre, :user
end
+1 -6
ファイルの表示
@@ -85,14 +85,9 @@ Rails.application.routes.draw do
member do member do
put :watching put :watching
patch :next_post patch :next_post
put :skip_vote
delete :skip_vote, action: :unskip_vote
get :post_selection_weights
end end
resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] resources :comments, controller: :theatre_comments, only: [:index, :create]
resources :programmes, controller: :theatre_programmes, only: [:index]
resources :skip_events, controller: :theatre_skip_events, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] resources :materials, only: [:index, :show, :create, :update, :destroy]
-10
ファイルの表示
@@ -1,10 +0,0 @@
class CreateTheatreProgrammes < ActiveRecord::Migration[8.0]
def change
create_table :theatre_programmes, primary_key: [:theatre_id, :position] do |t|
t.references :theatre, null: false, foreign_key: true
t.integer :position, null: false
t.references :post, null: false, foreign_key: true
t.datetime :created_at, null: false
end
end
end
-36
ファイルの表示
@@ -1,36 +0,0 @@
class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0]
def change
create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
t.timestamps
end
create_table :theatre_skip_events do |t|
t.references :theatre, null: false, foreign_key: true
t.references :post, null: false, foreign_key: true
t.references :skipped_by_user, null: false, foreign_key: { to_table: :users }
t.integer :programme_position
t.datetime :created_at, null: false
end
create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :user, null: false, foreign_key: true
end
create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t|
t.references :theatre_skip_event, null: false, foreign_key: true
t.references :tag, null: false, foreign_key: true
end
add_index :theatre_skip_events, [:theatre_id, :created_at]
add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at],
name: 'idx_theatre_skip_votes_theatre_post_created'
add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_voters_user_event'
add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id],
name: 'idx_theatre_skip_event_tags_tag_event'
end
end
生成ファイル
+1 -62
ファイルの表示
@@ -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_06_06_000000) do ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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
@@ -283,55 +283,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
t.index ["user_id"], name: "index_theatre_comments_on_user_id" t.index ["user_id"], name: "index_theatre_comments_on_user_id"
end end
create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.integer "position", null: false
t.bigint "post_id", null: false
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_programmes_on_post_id"
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end
create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "tag_id", null: false
t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event"
t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id"
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id"
end
create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_skip_event_id", null: false
t.bigint "user_id", null: false
t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id"
t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event"
t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id"
end
create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "skipped_by_user_id", null: false
t.integer "programme_position"
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_skip_events_on_post_id"
t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id"
t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at"
t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id"
end
create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.bigint "post_id", null: false
t.bigint "user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id"
t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created"
t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id"
t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id"
end
create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false t.bigint "theatre_id", null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
@@ -513,18 +464,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_skip_event_tags", "tags"
add_foreign_key "theatre_skip_event_tags", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "theatre_skip_events"
add_foreign_key "theatre_skip_event_voters", "users"
add_foreign_key "theatre_skip_events", "posts"
add_foreign_key "theatre_skip_events", "theatres"
add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id"
add_foreign_key "theatre_skip_votes", "posts"
add_foreign_key "theatre_skip_votes", "theatres"
add_foreign_key "theatre_skip_votes", "users"
add_foreign_key "theatre_watching_users", "theatres" add_foreign_key "theatre_watching_users", "theatres"
add_foreign_key "theatre_watching_users", "users" add_foreign_key "theatre_watching_users", "users"
add_foreign_key "theatres", "posts", column: "current_post_id" add_foreign_key "theatres", "posts", column: "current_post_id"
-47
ファイルの表示
@@ -1,47 +0,0 @@
require 'rails_helper'
RSpec.describe 'error responses', type: :request do
describe 'manual input errors' do
it 'returns a stable payload for bad requests' do
get '/tags/name/%20/deerjikists'
expect(response).to have_http_status(:bad_request)
expect(json).to include(
'type' => 'bad_request',
'message' => be_present,
'errors' => {},
'base_errors' => [be_present])
end
it 'returns a stable field-error payload for unprocessable requests' do
member = create(:user, :member)
tag = create(:tag, :general, name: 'error_response_tag')
sign_in_as(member)
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'category' => ['ニコタグは変更できません.'])
end
end
describe 'model validation errors' do
it 'returns field messages for model errors' do
user = create(:user)
sign_in_as(user)
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
expect(json.fetch('errors').fetch('name')).to include(be_present)
end
end
end
+8 -18
ファイルの表示
@@ -141,21 +141,16 @@ RSpec.describe 'Materials API', type: :request do
context 'when logged in' do context 'when logged in' do
before { sign_in_as(guest_user) } before { sign_in_as(guest_user) }
it 'returns 422 when tag is blank' do it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload } post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' } post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'creates a material with an attached file' do it 'creates a material with an attached file' do
@@ -266,26 +261,21 @@ RSpec.describe 'Materials API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'returns 422 when tag is blank' do it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: ' ', tag: ' ',
file: dummy_upload file: dummy_upload
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tag' => ['タグは必須です.'])
end end
it 'returns 422 when both file and url are blank' do it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: { put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload' tag: 'material_update_no_payload'
} }
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'file' => ['ファイルまたは URL は必須です.'],
'url' => ['ファイルまたは URL は必須です.'])
end end
it 'updates tag, url, file, and updated_by_user' do it 'updates tag, url, file, and updated_by_user' do
+7 -93
ファイルの表示
@@ -3,68 +3,12 @@ require 'rails_helper'
RSpec.describe 'NicoTags', type: :request do RSpec.describe 'NicoTags', type: :request do
describe 'GET /tags/nico' do describe 'GET /tags/nico' do
it 'returns paginated tags and total count' do it 'returns tags and next_cursor when overflowing limit' do
create_list(:tag, 3, :nico) create_list(:tag, 21, :nico)
get '/tags/nico', params: { limit: 20 }
get '/tags/nico', params: { page: 2, limit: 2 }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(json['tags'].size).to eq(1) expect(json['tags'].size).to eq(20)
expect(json['count']).to eq(3) expect(json['next_cursor']).to be_present
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:tag, :nico)
linked.tag_name.update!(name: 'nico:search_linked')
unlinked = create(:tag, :nico)
unlinked.tag_name.update!(name: 'nico:search_unlinked')
other = create(:tag, :nico)
other.tag_name.update!(name: 'nico:other')
destination = create(:tag, :general)
destination.tag_name.update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
NicoTagRelation.create!(nico_tag: other, tag: create(:tag, :general))
get '/tags/nico', params: {
name: 'search_',
linked_tag: 'destination_',
link_status: 'linked'
}
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([linked.id])
get '/tags/nico', params: { name: 'search_', link_status: 'unlinked' }
expect(json['count']).to eq(1)
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([unlinked.id])
end
it 'sorts by name and timestamps' do
older = create(:tag, :nico)
older.tag_name.update!(name: 'nico:a')
older.update_columns(created_at: 2.days.ago)
newer = create(:tag, :nico)
newer.tag_name.update!(name: 'nico:b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-older'), tag: older)
older_post_tag.update_columns(created_at: 1.hour.ago)
newer_post_tag =
PostTag.create!(post: Post.create!(url: 'https://example.com/nico-newer'), tag: newer)
newer_post_tag.update_columns(created_at: 2.hours.ago)
get '/tags/nico', params: { order: 'name:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([newer.id, older.id])
get '/tags/nico', params: { order: 'created_at:asc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
get '/tags/nico', params: { order: 'updated_at:desc' }
expect(json.fetch('tags').map { |tag| tag['id'] }).to eq([older.id, newer.id])
expect(Time.zone.parse(json.fetch('tags').first.fetch('recent_post_tag_created_at')))
.to be_within(1.second).of(older_post_tag.created_at)
end end
end end
@@ -131,7 +75,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(versions.last.created_by_user_id).to eq(admin.id) expect(versions.last.created_by_user_id).to eq(admin.id)
end end
it 'returns 422 when linked tag normalises to nico tag' do it '400 when linked tag normalises to nico tag' do
sign_in_as(member) sign_in_as(member)
other_nico = create(:tag, :nico, name: 'nico:linked_ng') other_nico = create(:tag, :nico, name: 'nico:linked_ng')
@@ -143,37 +87,7 @@ RSpec.describe 'NicoTags', type: :request do
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' } patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
}.not_to change(NicoTagVersion, :count) }.not_to change(NicoTagVersion, :count)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns the tags field error when a nico tag is specified directly' do
sign_in_as(member)
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'nico:linked_ng' }
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグ同士は連携できません.'])
end
it 'returns tag name validation errors on the tags field and rolls back created tags' do
sign_in_as(member)
TagNameSanitisationRule.create!(
priority: 1,
source_pattern: 'invalid',
replacement: 'valid'
)
nico_tag
expect {
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'created_first invalid' }
}.not_to change(TagName, :count)
expect(response).to have_http_status(:unprocessable_entity)
expect(json.fetch('errors').fetch('tags')).to include(
a_string_including('タグ名 “invalid”:', '名前に使用できない文字が含まれてゐます.'))
end end
end end
end end
+4 -86
ファイルの表示
@@ -57,23 +57,6 @@ RSpec.describe 'Posts API', type: :request do
post_write_params({ base_version_no: base_version.version_no }.merge(params)) post_write_params({ base_version_no: base_version.version_no }.merge(params))
end end
def count_sql_queries
count = 0
callback = lambda do |_name, _started, _finished, _id, payload|
next if payload[:cached]
next if ['SCHEMA', 'TRANSACTION'].include?(payload[:name])
count += 1
end
ActiveSupport::Notifications.subscribed(callback, 'sql.active_record') do
yield
end
count
end
let!(:tag_name) { TagName.create!(name: 'spec_tag') } let!(:tag_name) { TagName.create!(name: 'spec_tag') }
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
@@ -575,59 +558,6 @@ RSpec.describe 'Posts API', type: :request do
expect(sibling_ids).to include(sibling_post.id) expect(sibling_ids).to include(sibling_post.id)
end end
end end
it 'does not issue a query per tag or related post' do
user = create_member_user!
tags =
15.times.map do |i|
tag_name = TagName.create!(name: "show_query_tag_#{ i }")
tag = Tag.create!(tag_name:, category: :general)
TagName.create!(name: "show_query_alias_#{ i }", canonical: tag_name)
PostTag.create!(post: post_record, tag:)
tag
end
tags.each_cons(2) do |parent_tag, child_tag|
TagImplication.create!(parent_tag:, tag: child_tag)
end
parent_post = Post.create!(
title: 'query parent post',
url: 'https://example.com/query-parent-post'
)
sibling_post = Post.create!(
title: 'query sibling post',
url: 'https://example.com/query-sibling-post'
)
child_post = Post.create!(
title: 'query child post',
url: 'https://example.com/query-child-post'
)
PostImplication.create!(post: post_record, parent_post:)
PostImplication.create!(post: sibling_post, parent_post:)
PostImplication.create!(post: child_post, parent_post: post_record)
20.times do |i|
related_post = Post.create!(
title: "query related post #{ i }",
url: "https://example.com/query-related-post-#{ i }"
)
PostSimilarity.create!(post: post_record,
target_post: related_post,
cos: 1.0 - (i / 100.0))
end
query_count =
count_sql_queries do
get "/posts/#{ post_record.id }",
headers: { 'X-Transfer-Code' => user.inheritance_code }
end
expect(response).to have_http_status(:ok)
expect(query_count).to be <= 45
end
end end
context 'when post does not exist' do context 'when post does not exist' do
@@ -704,7 +634,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'returns 422 with tag field errors' do it 'return 400' do
sign_in_as(member) sign_in_as(member)
post '/posts', params: post_write_params( post '/posts', params: post_write_params(
@@ -714,13 +644,7 @@ RSpec.describe 'Posts API', type: :request do
thumbnail: dummy_upload thumbnail: dummy_upload
) )
expect(response).to have_http_status(:unprocessable_entity), response.body expect(response).to have_http_status(:bad_request), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end end
end end
@@ -937,7 +861,7 @@ RSpec.describe 'Posts API', type: :request do
category: :nico) category: :nico)
end end
it 'returns 422 with tag field errors' do it 'return 400' do
sign_in_as(member) sign_in_as(member)
put "/posts/#{post_record.id}", params: post_update_params( put "/posts/#{post_record.id}", params: post_update_params(
@@ -945,13 +869,7 @@ RSpec.describe 'Posts API', type: :request do
title: 'updated title', title: 'updated title',
tags: 'nico:nico_tag') tags: 'nico:nico_tag')
expect(response).to have_http_status(:unprocessable_entity), response.body expect(response).to have_http_status(:bad_request), response.body
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'tags' => ['ニコニコ・タグは直接指定できません.'])
end end
end end
-24
ファイルの表示
@@ -275,30 +275,6 @@ RSpec.describe 'Tags deerjikists API', type: :request do
end end
end end
context 'when a row is invalid' do
let(:payload) do
[
{ platform: '', code: code1 },
]
end
it 'returns 422 with indexed field errors and does not replace existing deerjikists' do
Deerjikist.create!(platform: platform1, code: code1, tag: tag)
expect {
do_request
}.not_to change { Deerjikist.where(tag: tag).map { |d| [d.platform, d.code] } }
expect(response).to have_http_status(:unprocessable_entity)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
'base_errors' => [])
expect(json.fetch('errors')).to include(
'deerjikists.0.platform' => [be_present])
end
end
context 'when youtube code is handle' do context 'when youtube code is handle' do
let(:channel_id) { 'UCabcdefghijklmnopqrstuv' } let(:channel_id) { 'UCabcdefghijklmnopqrstuv' }
let(:payload) do let(:payload) do
-60
ファイルの表示
@@ -80,26 +80,6 @@ RSpec.describe 'TheatreComments', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
end end
it '削除済みコメントは deleted として返し、本文を隠す' do
comment_2.discard!
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
deleted_comment = response.parsed_body.find { _1['no'] == 2 }
expect(deleted_comment).to include(
'deleted' => true,
'content' => nil
)
visible_comment = response.parsed_body.find { _1['no'] == 3 }
expect(visible_comment).to include(
'deleted' => false,
'content' => 'third comment'
)
end
end end
describe 'POST /theatres/:theatre_id/comments' do describe 'POST /theatres/:theatre_id/comments' do
@@ -167,44 +147,4 @@ RSpec.describe 'TheatreComments', type: :request do
}) })
end end
end end
describe 'DELETE /theatres/:theatre_id/comments/:id' do
let(:theatre) { create(:theatre) }
let(:alice) { create(:user, name: 'Alice') }
let(:bob) { create(:user, name: 'Bob') }
let!(:comment) do
create(
:theatre_comment,
theatre: theatre,
no: 1,
user: alice,
content: 'delete target'
)
end
it 'returns 401 when not logged in' do
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:unauthorized)
expect(comment.reload.discarded?).to eq(false)
end
it 'allows the comment owner to delete it' do
sign_in_as(alice)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:no_content)
expect(comment.reload.discarded?).to eq(true)
end
it 'returns 403 when another user tries to delete it' do
sign_in_as(bob)
delete "/theatres/#{theatre.id}/comments/#{comment.no}"
expect(response).to have_http_status(:forbidden)
expect(comment.reload.discarded?).to eq(false)
end
end
end end
-38
ファイルの表示
@@ -1,38 +0,0 @@
require 'rails_helper'
RSpec.describe 'TheatreProgrammes', type: :request do
describe 'GET /theatres/:theatre_id/programmes' do
let(:theatre) { create(:theatre) }
let(:other_theatre) { create(:theatre) }
let(:post_1) { Post.create!(title: 'first', url: 'https://www.nicovideo.jp/watch/sm1') }
let(:post_2) { Post.create!(title: 'second', url: 'https://www.nicovideo.jp/watch/sm2') }
let(:other_post) { Post.create!(title: 'other', url: 'https://www.nicovideo.jp/watch/sm3') }
before do
TheatreProgramme.create!(theatre:, position: 1, post: post_1, created_at: 2.minutes.ago)
TheatreProgramme.create!(theatre:, position: 2, post: post_2, created_at: 1.minute.ago)
TheatreProgramme.create!(
theatre: other_theatre,
position: 1,
post: other_post,
created_at: 1.minute.ago
)
end
it 'returns programmes for the theatre in descending position with post json' do
get "/theatres/#{theatre.id}/programmes"
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2, 1])
expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first'])
expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url)
end
it 'filters programmes by position_gt' do
get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 }
expect(response).to have_http_status(:ok)
expect(json.map { _1['position'] }).to eq([2])
end
end
end
+8 -226
ファイルの表示
@@ -14,24 +14,10 @@ RSpec.describe 'Theatres API', type: :request do
let(:member) { create(:user, :member, name: 'member user') } let(:member) { create(:user, :member, name: 'member user') }
let(:other_user) { create(:user, :member, name: 'other user') } let(:other_user) { create(:user, :member, name: 'other user') }
let!(:niconico_post) do
Post.create!(
title: 'niconico post',
url: 'https://www.nicovideo.jp/watch/sm123'
)
end
let!(:second_niconico_post) do
Post.create!(
title: 'second niconico post',
url: 'https://www.nicovideo.jp/watch/sm456'
)
end
let!(:youtube_post) do let!(:youtube_post) do
Post.create!( Post.create!(
title: 'youtube post', title: 'youtube post',
url: 'https://www.youtube.com/watch?v=yt123' url: 'https://www.youtube.com/watch?v=spec123'
) )
end end
@@ -134,8 +120,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => true, 'host_flg' => true,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil, 'post_started_at' => nil
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -192,8 +177,7 @@ RSpec.describe 'Theatres API', type: :request do
expect(json).to include( expect(json).to include(
'host_flg' => false, 'host_flg' => false,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil, 'post_started_at' => nil
'post_elapsed_ms' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly( expect(json.fetch('watching_users')).to contain_exactly(
@@ -220,7 +204,7 @@ RSpec.describe 'Theatres API', type: :request do
) )
theatre.update!( theatre.update!(
host_user: other_user, host_user: other_user,
current_post: niconico_post, current_post: youtube_post,
current_post_started_at: started_at current_post_started_at: started_at
) )
sign_in_as(member) sign_in_as(member)
@@ -236,11 +220,9 @@ RSpec.describe 'Theatres API', type: :request do
expect(theatre.host_user_id).to eq(member.id) expect(theatre.host_user_id).to eq(member.id)
expect(json['host_flg']).to eq(true) expect(json['host_flg']).to eq(true)
expect(json['post_id']).to eq(niconico_post.id) expect(json['post_id']).to eq(youtube_post.id)
expect(Time.zone.parse(json['post_started_at'])) expect(Time.zone.parse(json['post_started_at']))
.to be_within(1.second).of(started_at) .to be_within(1.second).of(started_at)
expect(json['post_elapsed_ms'])
.to be_within(1_000).of(120_000)
end end
end end
end end
@@ -291,36 +273,16 @@ RSpec.describe 'Theatres API', type: :request do
it 'sets current_post to an eligible post and updates current_post_started_at' do it 'sets current_post to an eligible post and updates current_post_started_at' do
expect { do_request } expect { do_request }
.to change { theatre.reload.current_post_id } .to change { theatre.reload.current_post_id }
.from(nil).to(youtube_post.id)
expect(response).to have_http_status(:no_content) expect(response).to have_http_status(:no_content)
expect([niconico_post.id, second_niconico_post.id, youtube_post.id])
.to include(theatre.reload.current_post_id)
expect(theatre.reload.current_post_started_at) expect(theatre.reload.current_post_started_at)
.to be_within(1.second).of(Time.current) .to be_within(1.second).of(Time.current)
expect(theatre.programmes.count).to eq(1)
end
end
context 'when only a YouTube post is eligible' do
before do
niconico_post.destroy!
second_niconico_post.destroy!
theatre.update!(host_user: member)
sign_in_as(member)
end
it 'sets current_post to the YouTube post' do
do_request
expect(response).to have_http_status(:no_content)
expect(theatre.reload.current_post_id).to eq(youtube_post.id)
end end
end end
context 'when current user is host and no eligible post exists' do context 'when current user is host and no eligible post exists' do
before do before do
niconico_post.destroy!
second_niconico_post.destroy!
youtube_post.destroy! youtube_post.destroy!
theatre.update!( theatre.update!(
host_user: member, host_user: member,
@@ -337,189 +299,9 @@ RSpec.describe 'Theatres API', type: :request do
theatre.reload theatre.reload
expect(theatre.current_post_id).to be_nil expect(theatre.current_post_id).to be_nil
expect(theatre.current_post_started_at).to be_nil expect(theatre.current_post_started_at)
.to be_within(1.second).of(Time.current)
end end
end end
end end
describe 'PUT /theatres/:id/skip_vote' do
subject(:do_request) do
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
end
let(:third_user) { create(:user, :member, name: 'third user') }
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
[member, other_user, third_user].each do |user|
TheatreWatchingUser.create!(
theatre:,
user:,
expires_at: 10.seconds.from_now
)
end
end
it 'returns 401 when not logged in' do
sign_out
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'returns 422 when post_id is invalid' do
sign_in_as(member)
expect {
put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'records a vote and returns the current vote status before majority' do
sign_in_as(member)
expect { do_request }.to change(TheatreSkipVote, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(false)
expect(json['post_id']).to eq(niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 1,
'required_count' => 2,
'watching_users_count' => 3,
'voted' => true
)
end
it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do
tag = create(:tag, name: 'skip-target')
PostTag.create!(post: niconico_post, tag:)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(other_user)
expect { do_request }
.to change(TheatreSkipEvent, :count).by(1)
.and change(TheatreSkipEventVoter, :count).by(2)
.and change(TheatreSkipEventTag, :count).by(1)
expect(response).to have_http_status(:ok)
expect(json['skipped']).to eq(true)
expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id'])
event = TheatreSkipEvent.last
expect(event.post).to eq(niconico_post)
expect(event.users).to contain_exactly(member, other_user)
expect(event.tags).to contain_exactly(tag)
expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty
end
it 'does not record a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
sign_in_as(member)
expect { do_request }.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'voted' => false
)
end
end
describe 'DELETE /theatres/:id/skip_vote' do
let(:requested_post_id) { niconico_post.id }
before do
theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
TheatreSkipVote.create!(theatre:, post: niconico_post, user: member)
sign_in_as(member)
end
it 'removes the current user vote' do
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.to change(TheatreSkipVote, :count).by(-1)
expect(response).to have_http_status(:ok)
expect(json['skip_vote']).to include(
'votes_count' => 0,
'required_count' => 1,
'watching_users_count' => 1,
'voted' => false
)
end
it 'does not remove a vote when requested post is no longer current' do
theatre.update!(current_post: second_niconico_post)
expect {
delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id }
}.not_to change(TheatreSkipVote, :count)
expect(response).to have_http_status(:conflict)
expect(json['post_id']).to eq(second_niconico_post.id)
end
end
describe 'GET /theatres/:id/skip_events' do
before do
sign_in_as(member)
end
it 'does not expose skip voters' do
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
get "/theatres/#{theatre.id}/skip_events"
expect(response).to have_http_status(:ok)
expect(json.first).to include(
'id' => event.id,
'theatre_id' => theatre.id
)
expect(json.first).not_to have_key('voters')
expect(json.first).not_to have_key('skipped_by_user')
end
end
describe 'GET /theatres/:id/post_selection_weights' do
before do
theatre.update!(current_post: niconico_post)
TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now)
sign_in_as(member)
end
it 'returns tag penalties and candidate weights for the current watchers' do
tag = create(:tag, name: 'heavy-tag')
PostTag.create!(post: second_niconico_post, tag:)
event = TheatreSkipEvent.create!(
theatre:,
post: niconico_post,
skipped_by_user: member,
created_at: Time.current
)
TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member)
TheatreSkipEventTag.create!(theatre_skip_event: event, tag:)
get "/theatres/#{theatre.id}/post_selection_weights"
expect(response).to have_http_status(:ok)
expect(json['tag_penalties'].first['penalty']).to eq(1)
expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id)
expect(json['lightest_posts'].first['penalty']).to eq(1)
end
end
end end
+2 -4
ファイルの表示
@@ -90,14 +90,12 @@ RSpec.describe 'Users', type: :request do
expect(response).to have_http_status(:unauthorized) expect(response).to have_http_status(:unauthorized)
end end
it 'returns 422 when name is blank' do it 'returns 400 when name is blank' do
put "/users/#{user.id}", put "/users/#{user.id}",
params: { name: ' ' }, params: { name: ' ' },
headers: auth_headers(user) headers: auth_headers(user)
expect(response).to have_http_status(:unprocessable_entity) expect(response).to have_http_status(:bad_request)
expect(json.fetch('errors')).to include(
'name' => ['名前は必須です.'])
end end
it 'updates name and returns user slice' do it 'updates name and returns user slice' do
+2 -3
ファイルの表示
@@ -18,13 +18,12 @@ npm install
npm run dev npm run dev
npm run build npm run build
npm run lint npm run lint
npm run test npm test
npm run test:run
``` ```
### Full verification ### Full verification
```sh ```sh
cd backend && bundle exec rspec cd backend && bundle exec rspec
cd ../frontend && npm run test:run && npm run build && npm run lint cd ../frontend && npm run build && npm run lint
``` ```
+16 -68
ファイルの表示
@@ -4,8 +4,7 @@
These rules apply to work under `frontend/`. These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands ## Commands
@@ -18,11 +17,9 @@ npm run lint
npm run preview npm run preview
``` ```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
`npm test` unless a test script is added.
After frontend changes, run: After frontend changes, run:
@@ -35,37 +32,18 @@ If either command cannot be run or fails, report the exact command and failure.
## TypeScript ## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, - TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities. - Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports. - Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types. - Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including - Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better. - Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- For arrays, never put whitespace or a line break immediately before `]`.
- Keep the first element on the same line as `[` by default.
- If an array would exceed the line limit, break after `[` and indent
elements by 4 spaces.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
## React ## React
- Use function components. - Use function components.
- Existing page components commonly export an anonymous function satisfying - Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
`FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks. - React hooks must be called unconditionally and at the top level of components or custom hooks.
- Gate editing and other privileged controls with shared permission helpers
such as `canEditContent`, instead of showing controls and relying only on a
later API failure.
- Keep page-level components under `src/pages`. - Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`. - Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`. - Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
@@ -74,23 +52,17 @@ If either command cannot be run or fails, report the exact command and failure.
## TanStack Query ## TanStack Query
- Use `@tanstack/react-query` for server state. - Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there - Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
instead of using ad hoc arrays in components. - Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Fetch functions should live in domain helpers under `src/lib`, such as - Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
`posts.ts`, `tags.ts`, or `wiki.ts`. - The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
- Use `useQueryClient().invalidateQueries` with the shared root keys when
mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
additional clients in feature code.
## API calls ## API calls
- Use `src/lib/api.ts` for HTTP calls. - Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts - The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them. - Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a - Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body. - For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases ## Imports and aliases
@@ -104,41 +76,17 @@ If either command cannot be run or fails, report the exact command and failure.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`. - Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging. - Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and - Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components. - Keep Tailwind classes consistent with nearby components.
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds, - When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
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.
## TSX formatting
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Avoid reformatting unrelated JSX.
## 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`, and `eslint-plugin-react-refresh`.
and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule. - The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`. - `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript - Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
errors, not lint-only issues.
## Files to avoid in routine work ## Files to avoid in routine work
+1 -1
ファイルの表示
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint' import tseslint from 'typescript-eslint'
export default tseslint.config( export default tseslint.config(
{ ignores: ['dist', 'tailwind.config.js'] }, { ignores: ['dist'] },
{ {
extends: [js.configs.recommended, ...tseslint.configs.recommended], extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'], files: ['**/*.{ts,tsx}'],
生成ファイル
+39 -1138
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読込み
+1 -9
ファイルの表示
@@ -8,8 +8,6 @@
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"postbuild": "node scripts/generate-sitemap.js", "postbuild": "node scripts/generate-sitemap.js",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -47,10 +45,6 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/axios": "^0.14.4", "@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
@@ -64,13 +58,11 @@
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"tailwindcss": "^3.4.13", "tailwindcss": "^3.4.13",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.30.1", "typescript-eslint": "^8.30.1",
"vite": "^6.3.5", "vite": "^6.3.5"
"vitest": "^4.1.5"
}, },
"description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.", "description": "This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.",
"main": "eslint.config.js", "main": "eslint.config.js",
+3 -6
ファイルの表示
@@ -62,9 +62,8 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags/:id" element={<TagDetailPage/>}/> <Route path="/tags/:id" element={<TagDetailPage/>}/>
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/> <Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
@@ -94,7 +93,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
} }
const App: FC = () => { export default (() => {
const [user, setUser] = useState<User | null> (null) const [user, setUser] = useState<User | null> (null)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
@@ -157,6 +156,4 @@ const App: FC = () => {
</DialogueProvider> </DialogueProvider>
</BrowserRouter> </BrowserRouter>
</>) </>)
} }) satisfies FC
export default App
+2 -4
ファイルの表示
@@ -19,7 +19,7 @@ type Props = {
sp?: boolean } sp?: boolean }
const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }) => { export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }: Props) => {
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)
@@ -96,6 +96,4 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</motion.div> </motion.div>
</div>) </div>)
} }) satisfies FC<Props>
export default DraggableDroppableTagRow
-32
ファイルの表示
@@ -1,32 +0,0 @@
import { render, screen } from '@testing-library/react'
import { HelmetProvider } from 'react-helmet-async'
import { describe, expect, it } from 'vitest'
import ErrorScreen from '@/components/ErrorScreen'
describe ('ErrorScreen', () => {
it.each ([
[403, '権限ないよ(笑)'],
[404, 'ページないよ(笑)'],
[500, '鯖でエラー出たって(嘲笑)'],
[503, '鯖死んでるよ(泣)'],
]) ('renders status %s', (status, message) => {
render (
<HelmetProvider>
<ErrorScreen status={status}/>
</HelmetProvider>,
)
expect (screen.getByText (String (status))).toBeInTheDocument ()
expect (screen.getByText (message)).toBeInTheDocument ()
expect (screen.getByAltText ('逃げたギター')).toBeInTheDocument ()
})
it ('throws for unsupported statuses', () => {
expect (() => render (
<HelmetProvider>
<ErrorScreen status={418}/>
</HelmetProvider>,
)).toThrow ()
})
})
+2 -4
ファイルの表示
@@ -10,7 +10,7 @@ import type { FC } from 'react'
type Props = { status: number } type Props = { status: number }
const ErrorScreen: FC<Props> = ({ status }) => { export default (({ status }: Props) => {
const [message, rightMsg, leftMsg]: [string, string, string] = (() => { const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
switch (status) switch (status)
{ {
@@ -58,6 +58,4 @@ const ErrorScreen: FC<Props> = ({ status }) => {
<p className="mr-[-.5em]">{message}</p> <p className="mr-[-.5em]">{message}</p>
</div> </div>
</MainArea>) </MainArea>)
} }) satisfies FC<Props>
export default ErrorScreen
+2 -4
ファイルの表示
@@ -31,7 +31,7 @@ const setChildrenById = (
})) }))
const MaterialSidebar: FC = () => { export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([]) const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ }) const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ }) const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
@@ -94,6 +94,4 @@ const MaterialSidebar: FC = () => {
{renderTags (tags)} {renderTags (tags)}
</ul> </ul>
</SidebarComponent>) </SidebarComponent>)
} }) satisfies FC
export default MaterialSidebar
+2 -4
ファイルの表示
@@ -1,11 +1,9 @@
import type { FC } from 'react' import type { FC } from 'react'
const MenuSeparator: FC = () => ( export default (() => (
<> <>
<span className="hidden md:inline flex items-center px-2">|</span> <span className="hidden md:inline flex items-center px-2">|</span>
<hr className="block md:hidden w-full opacity-25 <hr className="block md:hidden w-full opacity-25
border-t border-black dark:border-white"/> border-t border-black dark:border-white"/>
</>) </>)) satisfies FC
export default MenuSeparator
-83
ファイルの表示
@@ -1,83 +0,0 @@
import { act, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createRef } from 'react'
import NicoViewer from '@/components/NicoViewer'
import type { NiconicoViewerHandle } from '@/types'
describe ('NicoViewer', () => {
afterEach (() => {
vi.useRealTimers ()
})
it ('does not time out after metadata reports a playable duration', () => {
vi.useFakeTimers ()
const onError = vi.fn ()
const onMetadataChange = vi.fn ()
const { container } = render (
<NicoViewer
id="sm12345"
width={640}
height={360}
onMetadataChange={onMetadataChange}
onError={onError}/>,
)
const iframe = container.querySelector ('iframe')
expect (iframe).not.toBeNull ()
fireEvent.load (iframe!)
act (() => {
window.dispatchEvent (new MessageEvent ('message', {
origin: 'https://embed.nicovideo.jp',
source: iframe!.contentWindow,
data: {
eventName: 'playerMetadataChange',
data: {
currentTime: 7,
duration: 120,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
},
},
}))
})
act (() => {
vi.advanceTimersByTime (8_000)
})
expect (onMetadataChange).toHaveBeenCalled ()
expect (onError).not.toHaveBeenCalled ()
})
it ('seeks with milliseconds', () => {
const ref = createRef<NiconicoViewerHandle> ()
const { container } = render (
<NicoViewer
ref={ref}
id="sm12345"
width={640}
height={360}/>,
)
const iframe = container.querySelector ('iframe')!
const postMessage = vi.spyOn (iframe.contentWindow!, 'postMessage')
act (() => {
ref.current!.seek (7_000)
})
expect (postMessage).toHaveBeenCalledWith (
expect.objectContaining ({
eventName: 'seek',
data: { time: 7_000 },
}),
'https://embed.nicovideo.jp',
)
})
})
+40 -86
ファイルの表示
@@ -1,11 +1,11 @@
import { forwardRef, import { forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useImperativeHandle, useImperativeHandle,
useLayoutEffect, useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState } from 'react' useState } from 'react'
import type { CSSProperties, ForwardedRef } from 'react' import type { CSSProperties, ForwardedRef } from 'react'
@@ -14,20 +14,10 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage = type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete' | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } }
playerId?: string | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata }
data: { videoInfo: NiconicoVideoInfo } } | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown }
| { eventName: 'playerMetadataChange' | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string }
playerId?: string
data: NiconicoMetadata }
| { eventName: 'playerStatusChange' | 'statusChange'
playerId?: string
data?: unknown }
| { eventName: 'error'
playerId?: string
data?: unknown
code?: string
message?: string }
type NiconicoCommand = type NiconicoCommand =
| { eventName: 'play'; sourceConnectorType: 1; playerId: string } | { eventName: 'play'; sourceConnectorType: 1; playerId: string }
@@ -40,7 +30,6 @@ type NiconicoCommand =
data: { commentVisibility: boolean } } data: { commentVisibility: boolean } }
const EMBED_ORIGIN = 'https://embed.nicovideo.jp' const EMBED_ORIGIN = 'https://embed.nicovideo.jp'
const LOAD_COMPLETE_TIMEOUT_MS = 8_000
type Props = { type Props = {
id: string id: string
@@ -48,18 +37,14 @@ type Props = {
height: number height: number
style?: CSSProperties style?: CSSProperties
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void onMetadataChange?: (meta: NiconicoMetadata) => void }
onError?: (data: unknown) => void }
export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => { export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => {
const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null) const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
const playerId = useMemo (
() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`,
[id])
const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> () const [screenWidth, setScreenWidth] = useState<CSSProperties['width']> ()
const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> () const [screenHeight, setScreenHeight] = useState<CSSProperties['height']> ()
@@ -79,39 +64,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const styleFullScreen: CSSProperties = const styleFullScreen: CSSProperties =
fullScreen fullScreen
? { top: 0, ? { top: 0,
left: landscape ? 0 : '100%', left: landscape ? 0 : '100%',
position: 'fixed', position: 'fixed',
width: screenWidth, width: screenWidth,
height: screenHeight, height: screenHeight,
zIndex: 2_147_483_647, zIndex: 2_147_483_647,
maxWidth: 'none', maxWidth: 'none',
transformOrigin: '0% 0%', transformOrigin: '0% 0%',
transform: landscape ? 'none' : 'rotate(90deg)', transform: landscape ? 'none' : 'rotate(90deg)',
WebkitTransformOrigin: '0% 0%', WebkitTransformOrigin: '0% 0%',
WebkitTransform: landscape ? 'none' : 'rotate(90deg)' } WebkitTransform: landscape ? 'none' : 'rotate(90deg)' }
: { } : { }
const margedStyle: CSSProperties = const margedStyle: CSSProperties =
{ border: 'none', maxWidth: '100%', ...style, ...styleFullScreen } { border: 'none', maxWidth: '100%', ...style, ...styleFullScreen }
const clearLoadCompleteTimer = useCallback (() => {
if (!(loadCompleteTimerRef.current))
return
clearTimeout (loadCompleteTimerRef.current)
loadCompleteTimerRef.current = null
}, [])
const startLoadCompleteTimer = useCallback (() => {
clearLoadCompleteTimer ()
loadCompleteTimerRef.current = setTimeout (() => {
onError?.({
eventName: 'loadCompleteTimeout',
reason: 'niconico video length was not reported by embed',
})
}, LOAD_COMPLETE_TIMEOUT_MS)
}, [clearLoadCompleteTimer, onError])
const postToPlayer = useCallback ((message: NiconicoCommand) => { const postToPlayer = useCallback ((message: NiconicoCommand) => {
const win = iframeRef.current?.contentWindow const win = iframeRef.current?.contentWindow
if (!(win)) if (!(win))
@@ -129,9 +96,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const seek = useCallback ((time: number) => { const seek = useCallback ((time: number) => {
postToPlayer ( postToPlayer ({ eventName: 'seek', sourceConnectorType: 1, playerId, data: { time } })
{ eventName: 'seek', sourceConnectorType: 1, playerId,
data: { time } })
}, [playerId, postToPlayer]) }, [playerId, postToPlayer])
const mute = useCallback (() => { const mute = useCallback (() => {
@@ -167,21 +132,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
useEffect (() => { useEffect (() => {
const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => { const onMessage = (event: MessageEvent<NiconicoPlayerMessage>) => {
if (!(iframeRef.current) if (!(iframeRef.current)
|| (event.source !== iframeRef.current.contentWindow) || (event.source !== iframeRef.current.contentWindow)
|| (event.origin !== EMBED_ORIGIN)) || (event.origin !== EMBED_ORIGIN))
return return
const data = event.data const data = event.data
if (!(data) if (!(data)
|| typeof data !== 'object' || typeof data !== 'object'
|| !('eventName' in data)) || !('eventName' in data))
return return
if (('playerId' in data) if (('playerId' in data)
&& data.playerId && data.playerId
&& data.playerId !== playerId) && data.playerId !== playerId)
return return
if (data.eventName === 'enterProgrammaticFullScreen') if (data.eventName === 'enterProgrammaticFullScreen')
{ {
@@ -197,34 +162,24 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
if (data.eventName === 'loadComplete') if (data.eventName === 'loadComplete')
{ {
clearLoadCompleteTimer ()
onLoadComplete?.(data.data.videoInfo) onLoadComplete?.(data.data.videoInfo)
return return
} }
if (data.eventName === 'playerMetadataChange') if (data.eventName === 'playerMetadataChange')
{ {
if (Number.isFinite (data.data.duration) && data.data.duration > 0)
clearLoadCompleteTimer ()
onMetadataChange?.(data.data) onMetadataChange?.(data.data)
return return
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{ console.error ('niconico player error:', data)
clearLoadCompleteTimer ()
console.error ('niconico player error:', data)
onError?.(data)
}
} }
addEventListener ('message', onMessage) addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage)
}, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) }, [onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
@@ -237,7 +192,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const pollingResize = () => { const pollingResize = () => {
if (ended) if (ended)
return return
const isLandscape = innerWidth >= innerHeight const isLandscape = innerWidth >= innerHeight
const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px` const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px`
@@ -251,9 +206,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
const startPollingResize = () => { const startPollingResize = () => {
if (requestAnimationFrame) if (requestAnimationFrame)
requestAnimationFrame (pollingResize) requestAnimationFrame (pollingResize)
else else
pollingResize () pollingResize ()
} }
startPollingResize () startPollingResize ()
@@ -276,10 +231,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
<iframe <iframe
ref={iframeRef} ref={iframeRef}
src={src} src={src}
width={width} width={width}
height={height} height={height}
style={margedStyle} style={margedStyle}
onLoad={startLoadCompleteTimer} allowFullScreen
allowFullScreen allow="autoplay"/>)
allow="autoplay"/>)
}) })
-69
ファイルの表示
@@ -1,69 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostEditForm from '@/components/PostEditForm'
import { buildPost, buildTag } from '@/test/factories'
const postsApi = vi.hoisted (() => ({
updatePost: vi.fn (),
}))
const api = vi.hoisted (() => ({
isApiError: vi.fn (() => false),
}))
const toastApi = vi.hoisted (() => ({
toast: vi.fn (),
}))
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/lib/api', () => api)
vi.mock ('@/components/ui/use-toast', () => toastApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => ({
choice: vi.fn (),
}),
}))
describe ('PostEditForm', () => {
it ('submits edited post fields with the current base version', async () => {
const onSave = vi.fn ()
const post = buildPost ({
id: 8,
versionNo: 4,
title: 'old',
tags: [
buildTag ({ name: 'general-tag', category: 'general' }),
buildTag ({ id: 2, name: 'nico-tag', category: 'nico' }),
],
parentPosts: [buildPost ({ id: 2, title: 'parent' })],
})
postsApi.updatePost.mockResolvedValueOnce ({
...post,
versionNo: 5,
title: 'new',
tags: [buildTag ({ name: 'new-tag' })],
})
render (<PostEditForm post={post} onSave={onSave}/>)
const [title, parentIds] = screen.getAllByRole ('textbox')
fireEvent.change (title, { target: { value: 'new' } })
fireEvent.change (parentIds, { target: { value: '3 4' } })
fireEvent.submit (screen.getByRole ('button', { name: '更新' }).closest ('form')!)
await waitFor (() => {
expect (postsApi.updatePost).toHaveBeenCalledWith (
expect.objectContaining ({
id: 8,
title: 'new',
parentPostIds: '3 4',
tags: 'general-tag',
}),
{ baseVersionNo: 4 },
)
})
expect (onSave).toHaveBeenCalledWith (expect.objectContaining ({ versionNo: 5 }))
expect (toastApi.toast).toHaveBeenCalledWith ({ description: '更新しました.' })
})
})
+28 -52
ファイルの表示
@@ -2,23 +2,16 @@ 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'
import FieldError from '@/components/common/FieldError' import Label from '@/components/common/Label'
import FormField from '@/components/common/FormField'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button' 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 { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, Tag } from '@/types' import type { Post, Tag } from '@/types'
type PostFormField =
'parentPostIds' | 'tags' | 'originalCreatedAt'
const tagsToStr = (tags: Tag[]): string => { const tagsToStr = (tags: Tag[]): string => {
const result: Tag[] = [] const result: Tag[] = []
@@ -39,10 +32,8 @@ type Props = { post: Post
onSave: (newPost: Post) => void } onSave: (newPost: Post) => void }
const PostEditForm: FC<Props> = ({ post, onSave }) => { export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false) const [disabled, setDisabled] = useState (false)
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<PostFormField> ()
const [originalCreatedBefore, setOriginalCreatedBefore] = const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore) useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] = const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -55,8 +46,6 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const update = async (...args: Parameters<typeof updatePost>) => { const update = async (...args: Parameters<typeof updatePost>) => {
clearValidationErrors ()
try try
{ {
const data = await updatePost (...args) const data = await updatePost (...args)
@@ -73,18 +62,11 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
} }
catch (e) catch (e)
{ {
const response = isApiError<{ mergeable?: boolean }> (e) ? e.response : undefined const response = (e as any)?.response
if (response?.status !== 409) if (response?.status !== 409)
{ {
if (applyValidationError (e)) toast ({ description: '更新はできなかったよ……' })
{
toast ({ description: '更新はできなかったよ……' })
return
}
toast ({ title: '失敗……', description: '入力を確認してください.' })
return return
} }
@@ -139,38 +121,33 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
return ( return (
<form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4">
<FieldError messages={baseErrors}/>
{/* タイトル */} {/* タイトル */}
<FormField label="タイトル"> <div>
{({ invalid }) => ( <Label></Label>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
className={inputClass (invalid)} className="w-full border rounded p-2"
value={title ?? ''} value={title ?? ''}
onChange={ev => setTitle (ev.target.value)}/>)} onChange={ev => setTitle (ev.target.value)}/>
</FormField> </div>
{/* 親投稿 */} {/* 親投稿 */}
<FormField label="親投稿" messages={fieldErrors.parentPostIds}> <div>
{({ describedBy, invalid }) => ( <Label>稿</Label>
<input <input
type="text" type="text"
disabled={disabled} disabled={disabled}
value={parentPostIds} value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)} onChange={e => setParentPostIds (e.target.value)}
aria-describedby={describedBy} className="w-full border p-2 rounded"/>
aria-invalid={invalid} </div>
className={inputClass (invalid)}/>)}
</FormField>
{/* タグ */} {/* タグ */}
<PostFormTagsArea <PostFormTagsArea
disabled={disabled} disabled={disabled}
tags={tags} tags={tags}
setTags={setTags} setTags={setTags}/>
errors={fieldErrors.tags}/>
{/* オリジナルの作成日時 */} {/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField <PostOriginalCreatedTimeField
@@ -178,14 +155,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
originalCreatedFrom={originalCreatedFrom} originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom} setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore} originalCreatedBefore={originalCreatedBefore}
setOriginalCreatedBefore={setOriginalCreatedBefore} setOriginalCreatedBefore={setOriginalCreatedBefore}/>
errors={fieldErrors.originalCreatedAt}/>
{/* 送信 */} {/* 送信 */}
<Button type="submit" disabled={disabled}> <Button
type="submit"
disabled={disabled}>
</Button> </Button>
</form>) </form>)
} }) satisfies FC<Props>
export default PostEditForm
-128
ファイルの表示
@@ -1,128 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostEmbed from '@/components/PostEmbed'
import { buildPost } from '@/test/factories'
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
const nicoViewer = vi.hoisted (() => ({
props: vi.fn (),
}))
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/NicoViewer', () => ({
default: (props: { id: string }) => {
nicoViewer.props (props)
return <div>Nico:{props.id}</div>
},
}))
vi.mock ('react-youtube', () => ({
default: ({ videoId }: { videoId: string }) => <div>YouTube:{videoId}</div>,
}))
describe ('PostEmbed', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('embeds nicovideo watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}/>)
expect (screen.getByText ('Nico:sm12345')).toBeInTheDocument ()
})
it ('reports niconico metadata as milliseconds', () => {
const onVideoReady = vi.fn ()
const onPlaybackChange = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}
onPlaybackChange={onPlaybackChange}/>,
)
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledWith (120_000)
expect (onPlaybackChange).toHaveBeenCalledWith (7_000)
})
it ('reports niconico video readiness only once', () => {
const onVideoReady = vi.fn ()
render (
<PostEmbed
post={buildPost ({ url: 'https://www.nicovideo.jp/watch/sm12345' })}
onVideoReady={onVideoReady}/>,
)
nicoViewer.props.mock.calls[0][0].onLoadComplete ({
title: '動画',
videoId: 'sm12345',
lengthInSeconds: 120,
thumbnailUrl: 'https://example.com/thumb.jpg',
description: '',
viewCount: 1,
commentCount: 2,
mylistCount: 3,
postedAt: '2026-01-02T03:04:05.000Z',
watchId: 12345,
})
nicoViewer.props.mock.calls[0][0].onMetadataChange ({
currentTime: 7_000,
duration: 120_000,
isVideoMetaDataLoaded: true,
maximumBuffered: 30,
muted: false,
showComment: true,
volume: 1,
})
expect (onVideoReady).toHaveBeenCalledTimes (1)
expect (onVideoReady).toHaveBeenCalledWith (120_000)
})
it ('embeds x/twitter status URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://x.com/someone/status/12345' })}/>)
expect (screen.getByRole ('link', { name: '@someone' })).toBeInTheDocument ()
})
it ('embeds youtube watch URLs', () => {
render (<PostEmbed post={buildPost ({ url: 'https://www.youtube.com/watch?v=abc123' })}/>)
expect (screen.getByText ('YouTube:abc123')).toBeInTheDocument ()
})
it ('asks before framing unknown external pages', async () => {
dialogue.confirm.mockResolvedValueOnce (true)
render (
<PostEmbed
post={buildPost ({ url: 'https://example.com/page', title: 'external' })}/>,
)
fireEvent.click (screen.getByRole ('link', { name: '外部ページを表示' }))
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
expect (await screen.findByTitle ('external')).toHaveAttribute (
'src',
'https://example.com/page',
)
})
})
+10 -111
ファイルの表示
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react' import { useState } from 'react'
import YoutubeEmbed from 'react-youtube' import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
@@ -8,113 +8,16 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider'
import type { FC, RefObject } from 'react' import type { FC, RefObject } from 'react'
import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types'
import type { YouTubePlayer } from 'react-youtube'
type YouTubeEvent<T = unknown> = {
data: T
target: YouTubePlayer }
type Props = { type Props = {
ref?: RefObject<NiconicoViewerHandle | null> ref?: RefObject<NiconicoViewerHandle | null>
post: Post post: Post
onLoadComplete?: (info: NiconicoVideoInfo) => void onLoadComplete?: (info: NiconicoVideoInfo) => void
onMetadataChange?: (meta: NiconicoMetadata) => void onMetadataChange?: (meta: NiconicoMetadata) => void }
onVideoReady?: (durationMs: number) => void
onPlaybackChange?: (currentTimeMs: number) => number | void
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => {
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
const niconicoVideoReadyRef = useRef (false)
const notifyNiconicoVideoReady = useCallback ((durationMs: number) => {
if (niconicoVideoReadyRef.current
|| !(Number.isFinite (durationMs))
|| durationMs <= 0)
return
niconicoVideoReadyRef.current = true
onVideoReady?.(durationMs)
}, [onVideoReady])
const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => {
const currentTime = await player.getCurrentTime ()
const currentTimeMs = currentTime * 1_000
const targetTimeMs = onPlaybackChange?.(currentTimeMs)
if (typeof targetTimeMs !== 'number')
return
if (Math.abs (currentTimeMs - targetTimeMs) > 5_000)
await player.seekTo (targetTimeMs / 1_000, true)
}, [onPlaybackChange])
const handleYoutubeReady = async (event: YouTubeEvent) => {
setYoutubePlayer (event.target)
try
{
await event.target.playVideo ()
const duration = await event.target.getDuration ()
const durationMs = duration * 1_000
onVideoReady?.(durationMs)
if (!(Number.isFinite (durationMs)) || durationMs <= 0)
return
await reportYoutubePlayback (event.target)
}
catch (error)
{
onError?.({ platform: 'youtube', error })
}
}
const handleYoutubeStateChange = (event: YouTubeEvent<number>) => {
void reportYoutubePlayback (event.target)
}
const handleYoutubeError = (event: YouTubeEvent<number>) => {
onError?.({ platform: 'youtube', code: event.data })
}
const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => {
notifyNiconicoVideoReady (info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
notifyNiconicoVideoReady (meta.duration)
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
useEffect (() => {
niconicoVideoReadyRef.current = false
}, [post.url])
useEffect (() => {
if (!(youtubePlayer) || !(onPlaybackChange))
return
const timer = setInterval (
() => void reportYoutubePlayback (youtubePlayer),
1_000)
return () => clearInterval (timer)
}, [onPlaybackChange, reportYoutubePlayback, youtubePlayer])
const url = new URL (post.url) const url = new URL (post.url)
@@ -134,15 +37,14 @@ const PostEmbed: FC<Props> = ({
id={videoId} id={videoId}
width={640} width={640}
height={360} height={360}
onLoadComplete={handleNiconicoLoadComplete} onLoadComplete={onLoadComplete}
onMetadataChange={handleNiconicoMetadataChange} onMetadataChange={onMetadataChange}/>)
onError={onError}/>)
} }
case 'twitter.com': case 'twitter.com':
case 'x.com': case 'x.com':
{ {
const mUserId = url.pathname.match (/(?<=\/)[^/]+?(?=\/|$|\?)/) const mUserId = url.pathname.match (/(?<=\/)[^\/]+?(?=\/|$|\?)/)
const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/) const mStatusId = url.pathname.match (/(?<=\/status\/)\d+?(?=\/|$|\?)/)
if (!(mUserId) || !(mStatusId)) if (!(mUserId) || !(mStatusId))
break break
@@ -166,13 +68,12 @@ const PostEmbed: FC<Props> = ({
mute: 0, mute: 0,
loop: 1, loop: 1,
width: '640', width: '640',
height: '360' } }} height: '360' } }}/>)
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
} }
} }
const [framed, setFramed] = useState (false)
return ( return (
<> <>
{framed {framed
@@ -200,6 +101,4 @@ const PostEmbed: FC<Props> = ({
</a> </a>
</div>)} </div>)}
</>) </>)
} }) satisfies FC<Props>
export default PostEmbed
-34
ファイルの表示
@@ -1,34 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostFormTagsArea from '@/components/PostFormTagsArea'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('PostFormTagsArea', () => {
it ('updates text and fetches autocomplete for the selected token', async () => {
const setTags = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 3 })])
renderWithProviders (<PostFormTagsArea tags="虹" setTags={setTags}/>)
const textarea = screen.getByRole ('textbox')
fireEvent.focus (textarea)
fireEvent.select (textarea, { target: { selectionStart: 1, selectionEnd: 1 } })
fireEvent.change (textarea, { target: { value: '虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹', nico: '0' } },
)
})
expect (setTags).toHaveBeenCalledWith ('虹夏')
})
})
+28 -35
ファイルの表示
@@ -3,7 +3,7 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox' import TagSearchBox from '@/components/TagSearchBox'
import FormField from '@/components/common/FormField' import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea' import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
@@ -33,11 +33,10 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
tags: string tags: string
setTags: (tags: string) => void setTags: (tags: string) => void }
errors?: string[] }
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => { export default (({ tags, setTags, ...rest }: Props) => {
const ref = useRef<HTMLTextAreaElement> (null) const ref = useRef<HTMLTextAreaElement> (null)
const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -74,34 +73,28 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
} }
return ( return (
<FormField className="relative w-full" label="タグ" messages={errors}> <div className="relative w-full">
{({ describedBy, invalid }) => ( <Label></Label>
<> <TextArea
<TextArea {...rest}
{...rest} ref={ref}
ref={ref} value={tags}
value={tags} onChange={ev => setTags (ev.target.value)}
aria-describedby={describedBy} onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => {
invalid={invalid} const pos = (ev.target as HTMLTextAreaElement).selectionStart
onChange={ev => setTags (ev.target.value)} await recompute (pos)
onSelect={async (ev: SyntheticEvent<HTMLTextAreaElement>) => { }}
const pos = (ev.target as HTMLTextAreaElement).selectionStart onFocus={() => setFocused (true)}
await recompute (pos) onBlur={() => {
}} setFocused (false)
onFocus={() => setFocused (true)} setSuggestionsVsbl (false)
onBlur={() => { }}/>
setFocused (false) {focused && (
setSuggestionsVsbl (false) <TagSearchBox
}}/> suggestions={suggestionsVsbl && suggestions.length > 0
{focused && ( ? suggestions
<TagSearchBox : [] as Tag[]}
suggestions={suggestionsVsbl && suggestions.length > 0 activeIndex={-1}
? suggestions onSelect={handleTagSelect}/>)}
: [] as Tag[]} </div>)
activeIndex={-1} }) satisfies FC<Props>
onSelect={handleTagSelect}/>)}
</>)}
</FormField>)
}
export default PostFormTagsArea
-44
ファイルの表示
@@ -1,44 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import PostList from '@/components/PostList'
import { buildPost } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
const prefetchers = vi.hoisted (() => ({
prefetchForURL: vi.fn (),
}))
vi.mock ('@/lib/prefetchers', () => prefetchers)
describe ('PostList', () => {
beforeEach (() => {
prefetchers.prefetchForURL.mockResolvedValue (undefined)
})
it ('renders post thumbnails as links to post details', () => {
renderWithProviders (
<PostList posts={[
buildPost ({ id: 1, title: 'First', thumbnail: 'first.jpg' }),
buildPost ({ id: 2, title: null, url: 'https://example.com/second' }),
]}/>,
)
expect (screen.getByRole ('link', { name: 'First' })).toHaveAttribute (
'href',
'/posts/1',
)
expect (
screen.getByRole ('link', { name: 'https://example.com/second' }),
).toHaveAttribute ('href', '/posts/2')
})
it ('calls the optional click handler', () => {
const onClick = vi.fn ()
renderWithProviders (<PostList posts={[buildPost ()]} onClick={onClick}/>)
fireEvent.click (screen.getByRole ('link', { name: 'テスト投稿' }))
expect (onClick).toHaveBeenCalledTimes (1)
})
})
+2 -4
ファイルの表示
@@ -14,7 +14,7 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void } onClick?: (event: MouseEvent<HTMLElement>) => void }
const PostList: FC<Props> = ({ posts, onClick }) => { export default (({ posts, onClick }: Props) => {
const location = useLocation () const location = useLocation ()
const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey) const setForLocationKey = useSharedTransitionStore (s => s.setForLocationKey)
@@ -70,6 +70,4 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
</PrefetchLink>) </PrefetchLink>)
})} })}
</div>) </div>)
} }) satisfies FC<Props>
export default PostList
-63
ファイルの表示
@@ -1,63 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
describe ('PostOriginalCreatedTimeField', () => {
it ('updates from and before values', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom={null}
setOriginalCreatedFrom={setFrom}
originalCreatedBefore={null}
setOriginalCreatedBefore={setBefore}/>,
)
const inputs = screen.getAllByDisplayValue ('')
fireEvent.change (inputs[0], { target: { value: '2026-01-02T03:04' } })
fireEvent.change (inputs[1], { target: { value: '2026-01-03T03:04' } })
expect (setFrom).toHaveBeenCalledWith (expect.any (String))
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('infers an exclusive before value on blur', () => {
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom={null}
setOriginalCreatedFrom={vi.fn ()}
originalCreatedBefore={null}
setOriginalCreatedBefore={setBefore}/>,
)
const input = screen.getAllByDisplayValue ('')[0]
fireEvent.blur (input, { target: { value: '2026-01-02T03:04' } })
expect (setBefore).toHaveBeenCalledWith (expect.any (String))
})
it ('resets both values', () => {
const setFrom = vi.fn ()
const setBefore = vi.fn ()
render (
<PostOriginalCreatedTimeField
originalCreatedFrom="2026-01-01T00:00:00Z"
setOriginalCreatedFrom={setFrom}
originalCreatedBefore="2026-01-02T00:00:00Z"
setOriginalCreatedBefore={setBefore}/>,
)
const buttons = screen.getAllByRole ('button', { name: 'リセット' })
fireEvent.click (buttons[0])
fireEvent.click (buttons[1])
expect (setFrom).toHaveBeenCalledWith (null)
expect (setBefore).toHaveBeenCalledWith (null)
})
})
+61 -76
ファイルの表示
@@ -1,5 +1,5 @@
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import FormField from '@/components/common/FormField' import Label from '@/components/common/Label'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import type { FC } from 'react' import type { FC } from 'react'
@@ -9,81 +9,66 @@ type Props = {
originalCreatedFrom: string | null originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void setOriginalCreatedBefore: (x: string | null) => void }
errors?: string[] }
const PostOriginalCreatedTimeField: FC<Props> = ( export default (({ disabled,
{ disabled, originalCreatedFrom,
originalCreatedFrom, setOriginalCreatedFrom,
setOriginalCreatedFrom, originalCreatedBefore,
originalCreatedBefore, setOriginalCreatedBefore }: Props) => (
setOriginalCreatedBefore, <div>
errors }: Props, <Label></Label>
) => ( <div className="my-1 flex">
<FormField label="オリジナルの作成日時" messages={errors}> <div className="w-80">
{({ describedBy, invalid }) => ( <DateTimeField
<> className="mr-2"
<div className="my-1 flex"> disabled={disabled ?? false}
<div className="w-80"> value={originalCreatedFrom ?? undefined}
<DateTimeField onChange={setOriginalCreatedFrom}
className="mr-2" onBlur={ev => {
disabled={disabled ?? false} const v = ev.target.value
aria-describedby={describedBy} if (!(v))
aria-invalid={invalid} return
invalid={invalid}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
const v = ev.target.value
if (!(v))
return
const d = new Date (v) const d = new Date (v)
if (d.getMinutes () === 0 && d.getHours () === 0) if (d.getMinutes () === 0 && d.getHours () === 0)
d.setDate (d.getDate () + 1) d.setDate (d.getDate () + 1)
else else
d.setMinutes (d.getMinutes () + 1) d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (d.toISOString ()) setOriginalCreatedBefore (d.toISOString ())
}}/> }}/>
</div> </div>
<div> <div>
<Button <Button
className="bg-gray-600 text-white rounded" className="bg-gray-600 text-white rounded"
disabled={disabled} disabled={disabled}
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
</Button> </Button>
</div> </div>
</div> </div>
<div className="my-1 flex">
<div className="my-1 flex"> <div className="w-80">
<div className="w-80"> <DateTimeField
<DateTimeField className="mr-2"
className="mr-2" disabled={disabled}
disabled={disabled} value={originalCreatedBefore ?? undefined}
aria-describedby={describedBy} onChange={setOriginalCreatedBefore}/>
aria-invalid={invalid}
invalid={invalid} </div>
value={originalCreatedBefore ?? undefined} <div>
onChange={setOriginalCreatedBefore}/> <Button
className="bg-gray-600 text-white rounded"
</div> disabled={disabled}
<div> onClick={() => {
<Button setOriginalCreatedBefore (null)
className="bg-gray-600 text-white rounded" }}>
disabled={disabled}
onClick={() => { </Button>
setOriginalCreatedBefore (null) </div>
}}> </div>
</div>)) satisfies FC<Props>
</Button>
</div>
</div>
</>)}
</FormField>)
export default PostOriginalCreatedTimeField
-30
ファイルの表示
@@ -1,30 +0,0 @@
import { render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import RouteBlockerOverlay, { useOverlayStore } from '@/components/RouteBlockerOverlay'
describe ('RouteBlockerOverlay', () => {
afterEach (() => {
useOverlayStore.setState ({ active: false })
document.body.style.overflow = ''
document.body.removeAttribute ('aria-busy')
})
it ('renders nothing while inactive', () => {
useOverlayStore.setState ({ active: false })
const { container } = render (<RouteBlockerOverlay/>)
expect (container).toBeEmptyDOMElement ()
})
it ('renders a blocking progressbar and marks the body busy while active', () => {
useOverlayStore.setState ({ active: true })
render (<RouteBlockerOverlay/>)
expect (screen.getByRole ('progressbar', { name: 'Loading' })).toBeInTheDocument ()
expect (document.body).toHaveAttribute ('aria-busy', 'true')
expect (document.body.style.overflow).toBe ('hidden')
})
})
+2 -4
ファイルの表示
@@ -13,7 +13,7 @@ export const useOverlayStore = create<OverlayStore> (set => ({
setActive: v => set ({ active: v }) })) setActive: v => set ({ active: v }) }))
const RouteBlockerOverlay: FC = () => { export default (() => {
const active = useOverlayStore (s => s.active) const active = useOverlayStore (s => s.active)
useEffect (() => { useEffect (() => {
@@ -43,6 +43,4 @@ const RouteBlockerOverlay: FC = () => {
</div> </div>
</div> </div>
</div>) </div>)
} }) satisfies FC
export default RouteBlockerOverlay
-39
ファイルの表示
@@ -1,39 +0,0 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import SortHeader from '@/components/SortHeader'
import { renderWithProviders } from '@/test/render'
describe ('SortHeader', () => {
it ('toggles the active sort direction and resets the page', () => {
renderWithProviders (
<SortHeader
by="title"
label="タイトル"
currentOrder="title:asc"
defaultDirection={{ title: 'asc' }}/>,
{ route: '/posts?tags=x&page=4&order=title%3Aasc' },
)
expect (screen.getByRole ('link', { name: 'タイトル ▲' })).toHaveAttribute (
'href',
'/posts?tags=x&page=1&order=title%3Adesc',
)
})
it ('uses default direction for inactive fields', () => {
renderWithProviders (
<SortHeader
by="updated_at"
label="更新"
currentOrder="title:desc"
defaultDirection={{ title: 'asc', updated_at: 'desc' }}/>,
{ route: '/posts?page=2' },
)
expect (screen.getByRole ('link', { name: '更新' })).toHaveAttribute (
'href',
'/posts?page=1&order=updated_at%3Adesc',
)
})
})
+2 -4
ファイルの表示
@@ -151,7 +151,7 @@ const DropSlot = ({ cat }: { cat: Category }) => {
type Props = { post: Post; sp?: boolean } type Props = { post: Post; sp?: boolean }
const TagDetailSidebar: FC<Props> = ({ post, sp }) => { export default (({ post, sp }: Props) => {
sp = Boolean (sp) sp = Boolean (sp)
const qc = useQueryClient () const qc = useQueryClient ()
@@ -376,6 +376,4 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
</DragOverlay> </DragOverlay>
</DndContext> </DndContext>
</SidebarComponent>) </SidebarComponent>)
} }) satisfies FC<Props>
export default TagDetailSidebar
-45
ファイルの表示
@@ -1,45 +0,0 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TagLink from '@/components/TagLink'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TagLink', () => {
it ('links tag names to post search and shows counts', () => {
renderWithProviders (
<TagLink tag={buildTag ({ name: '虹 夏', postCount: 4 })}/>,
)
expect (screen.getByRole ('link', { name: '虹 夏' })).toHaveAttribute (
'href',
'/posts?tags=%E8%99%B9+%E5%A4%8F',
)
expect (screen.getByText ('4')).toBeInTheDocument ()
})
it ('links wiki markers to the correct detail route', () => {
renderWithProviders (
<TagLink tag={buildTag ({ hasWiki: true, name: 'a/b' })}/>,
)
expect (screen.getByRole ('link', { name: '?' })).toHaveAttribute (
'href',
'/wiki/a%2Fb',
)
})
it ('renders aliases and non-link tags when requested', () => {
renderWithProviders (
<TagLink
tag={buildTag ({ matchedAlias: '別名', name: '正式名' })}
linkFlg={false}
withWiki={false}
withCount={false}/>,
)
expect (screen.getByText ('別名')).toBeInTheDocument ()
expect (screen.getByText ('正式名')).toBeInTheDocument ()
expect (screen.queryByRole ('link')).not.toBeInTheDocument ()
})
})
+3 -5
ファイルの表示
@@ -27,12 +27,12 @@ type Props =
| PropsWithoutLink | PropsWithoutLink
const TagLink: FC<Props> = ({ tag, export default (({ tag,
nestLevel = 0, nestLevel = 0,
linkFlg = true, linkFlg = true,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
...props }) => { ...props }: Props) => {
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -126,6 +126,4 @@ const TagLink: FC<Props> = ({ tag,
{withCount && ( {withCount && (
<span className="ml-1">{tag.postCount}</span>)} <span className="ml-1">{tag.postCount}</span>)}
</>) </>)
} }) satisfies FC<Props>
export default TagLink
+3 -7
ファイルの表示
@@ -4,7 +4,6 @@ import { useEffect, useState } from 'react'
import { useNavigate, useLocation } from 'react-router-dom' import { useNavigate, useLocation } from 'react-router-dom'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
import TagSearchBox from './TagSearchBox' import TagSearchBox from './TagSearchBox'
@@ -13,7 +12,7 @@ import type { ChangeEvent, FC, KeyboardEvent } from 'react'
import type { Tag } from '@/types' import type { Tag } from '@/types'
const TagSearch: FC = () => { export default (() => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useNavigate ()
@@ -111,12 +110,9 @@ const TagSearch: FC = () => {
onFocus={() => setSuggestionsVsbl (true)} onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)} onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
className={inputClass (false, className="w-full px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-800 dark:text-white"/>
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]} <TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
activeIndex={activeIndex} activeIndex={activeIndex}
onSelect={handleTagSelect}/> onSelect={handleTagSelect}/>
</div>) </div>)
} }) satisfies FC
export default TagSearch
-30
ファイルの表示
@@ -1,30 +0,0 @@
import { fireEvent, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import TagSearchBox from '@/components/TagSearchBox'
import { buildTag } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TagSearchBox', () => {
it ('renders suggestions and selects tags on mouse down', () => {
const handleSelect = vi.fn ()
const tag = buildTag ({ id: 9, name: '候補', postCount: 2 })
renderWithProviders (
<TagSearchBox suggestions={[tag]} activeIndex={0} onSelect={handleSelect}/>,
)
fireEvent.mouseDown (screen.getByText ('候補'))
expect (handleSelect).toHaveBeenCalledWith (tag)
expect (screen.getByText ('2')).toBeInTheDocument ()
})
it ('renders nothing when suggestions are empty', () => {
const { container } = renderWithProviders (
<TagSearchBox suggestions={[]} activeIndex={-1} onSelect={vi.fn ()}/>,
)
expect (container).toBeEmptyDOMElement ()
})
})
+2 -4
ファイルの表示
@@ -10,7 +10,7 @@ type Props = { suggestions: Tag[]
onSelect: (tag: Tag) => void } onSelect: (tag: Tag) => void }
const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => { export default (({ suggestions, activeIndex, onSelect }: Props) => {
if (suggestions.length === 0) if (suggestions.length === 0)
return return
@@ -26,6 +26,4 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
<TagLink tag={tag} linkFlg={false} withWiki={false}/> <TagLink tag={tag} linkFlg={false} withWiki={false}/>
</li>))} </li>))}
</ul>) </ul>)
} }) satisfies FC<Props>
export default TagSearchBox
+2 -4
ファイルの表示
@@ -19,7 +19,7 @@ type Props = { posts: Post[]
onClick?: (event: MouseEvent<HTMLElement>) => void } onClick?: (event: MouseEvent<HTMLElement>) => void }
const TagSidebar: FC<Props> = ({ posts, onClick }) => { export default (({ posts, onClick }: Props) => {
const navigate = useNavigate () const navigate = useNavigate ()
const [tagsVsbl, setTagsVsbl] = useState (false) const [tagsVsbl, setTagsVsbl] = useState (false)
@@ -126,6 +126,4 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
</a> </a>
</SidebarComponent>) </SidebarComponent>)
} }) satisfies FC<Props>
export default TagSidebar
+15 -17
ファイルの表示
@@ -26,7 +26,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
pathName: string }): Menu => { pathName: string }): Menu => {
const postCount = tag?.postCount ?? 0 const postCount = tag?.postCount ?? 0
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^/]+/.test (pathName) && wikiId) const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
const wikiTitle = pathName.split ('/')[2] ?? '' const wikiTitle = pathName.split ('/')[2] ?? ''
const tagFlg = /^\/tags\/\d+/.test (pathName) const tagFlg = /^\/tags\/\d+/.test (pathName)
@@ -55,6 +55,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false }, { name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' }, { name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' }, { name: '新規', to: '/wiki/new' },
@@ -65,8 +71,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: wikiPageFlg }, visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false }, { name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false },
@@ -76,7 +80,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
} }
const TopNav: FC<Props> = ({ user }) => { export default (({ user }: Props) => {
const location = useLocation () const location = useLocation ()
const dirRef = useRef<(-1) | 1> (1) const dirRef = useRef<(-1) | 1> (1)
@@ -128,12 +132,8 @@ const TopNav: FC<Props> = ({ user }) => {
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const moreMenu = menu.filter (item =>
!(item.visible ?? true)
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
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 prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -159,12 +159,12 @@ const TopNav: FC<Props> = ({ user }) => {
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe () return () => unsubscribe ()
}, []) }, [activeIdx])
useEffect (() => { useEffect (() => {
setMenuOpen (false) setMenuOpen (false)
setOpenItemIdx (activeIdx) setOpenItemIdx (activeIdx)
}, [activeIdx, location]) }, [location])
return ( return (
<> <>
@@ -244,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div <motion.div
key="submenu-shell" key="submenu-shell"
layout layout
className="relative z-20 hidden md:block overflow-hidden className="relative hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950" bg-yellow-200 dark:bg-red-950"
animate={{ height: submenuHeight }} style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
@@ -257,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}> }}>
{moreVsbl {moreVsbl
? ( ? (
moreMenu.map ((item, i) => ( menu.map ((item, i) => (
<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
@@ -267,7 +267,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { 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-24">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
{item.subMenu {item.subMenu
@@ -433,6 +433,4 @@ const TopNav: FC<Props> = ({ user }) => {
</motion.div>)} </motion.div>)}
</AnimatePresence> </AnimatePresence>
</>) </>)
} }) satisfies FC<Props>
export default TopNav
-29
ファイルの表示
@@ -1,29 +0,0 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TopNavUser from '@/components/TopNavUser'
import { buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
describe ('TopNavUser', () => {
it ('renders nothing without a user', () => {
const { container } = renderWithProviders (<TopNavUser user={null}/>)
expect (container).toBeEmptyDOMElement ()
})
it ('links named users to settings', () => {
renderWithProviders (<TopNavUser user={buildUser ({ name: '山田' })}/>)
expect (screen.getByRole ('link', { name: '山田' })).toHaveAttribute (
'href',
'/users/settings',
)
})
it ('uses the anonymous display name', () => {
renderWithProviders (<TopNavUser user={buildUser ({ name: null })}/>)
expect (screen.getByRole ('link', { name: '名もなきニジラー' })).toBeInTheDocument ()
})
})
+2 -4
ファイルの表示
@@ -10,7 +10,7 @@ type Props = { user: User | null,
sp?: boolean } sp?: boolean }
const TopNavUser: FC<Props> = ({ user, sp }) => { export default (({ user, sp }: Props) => {
if (!(user)) if (!(user))
return return
@@ -28,6 +28,4 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
{user.name || '名もなきニジラー'} {user.name || '名もなきニジラー'}
</PrefetchLink> </PrefetchLink>
</>) </>)
} }) satisfies FC<Props>
export default TopNavUser
-19
ファイルの表示
@@ -1,19 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TwitterEmbed from '@/components/TwitterEmbed'
describe ('TwitterEmbed', () => {
it ('renders tweet and user links', () => {
render (<TwitterEmbed userId="user_name" statusId="12345"/>)
expect (screen.getByRole ('link', { name: '@user_name' })).toHaveAttribute (
'href',
'https://twitter.com/user_name?ref_src=twsrc%3Etfw',
)
expect (screen.getByRole ('link', { name: /\d/ })).toHaveAttribute (
'href',
'https://twitter.com/user_name/status/12345?ref_src=twsrc%5Etfw',
)
})
})
+2 -4
ファイルの表示
@@ -5,7 +5,7 @@ type Props = {
statusId: string } statusId: string }
const TwitterEmbed: FC<Props> = ({ userId, statusId }) => { export default (({ userId, statusId }: Props) => {
const now = (new Date).toLocaleDateString () const now = (new Date).toLocaleDateString ()
return ( return (
@@ -18,6 +18,4 @@ const TwitterEmbed: FC<Props> = ({ userId, statusId }) => {
</blockquote> </blockquote>
<script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/> <script async src="https://platform.twitter.com/widgets.js" charSet="utf-8"/>
</div>) </div>)
} }) satisfies FC<Props>
export default TwitterEmbed
+2 -4
ファイルの表示
@@ -25,7 +25,7 @@ const mdComponents = { a: (({ href, children }) => (
</a>))) } as const satisfies Components </a>))) } as const satisfies Components
const WikiBody: FC<Props> = ({ title, body }) => { export default (({ title, body }: Props) => {
const { data } = useQuery ({ const { data } = useQuery ({
enabled: Boolean (body), enabled: Boolean (body),
queryKey: wikiKeys.index ({ }), queryKey: wikiKeys.index ({ }),
@@ -39,6 +39,4 @@ const WikiBody: FC<Props> = ({ title, body }) => {
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
</ReactMarkdown>) </ReactMarkdown>)
} }) satisfies FC<Props>
export default WikiBody
-27
ファイルの表示
@@ -1,27 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import DateTimeField from '@/components/common/DateTimeField'
describe ('DateTimeField', () => {
it ('renders an ISO value as a datetime-local value', () => {
render (<DateTimeField aria-label="日時" value="2026-01-02T03:04:05.000Z"/>)
const input = screen.getByLabelText ('日時')
expect (input).toHaveValue ('2026-01-02T12:04')
})
it ('reports local changes as ISO strings and empty values as null', () => {
const handleChange = vi.fn ()
render (<DateTimeField aria-label="日時" onChange={handleChange}/>)
const input = screen.getByLabelText ('日時')
fireEvent.change (input, { target: { value: '2026-01-02T03:04' } })
fireEvent.change (input, { target: { value: '' } })
const first = handleChange.mock.calls[0]?.[0]
expect (new Date (first).getFullYear ()).toBe (2026)
expect (handleChange).toHaveBeenLastCalledWith (null)
})
})
+4 -17
ファイルの表示
@@ -22,11 +22,10 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string value?: string
onChange?: (isoUTC: string | null) => void onChange?: (isoUTC: string | null) => void
className?: string className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }
invalid?: boolean }
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => { export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
const [local, setLocal] = useState ('') const [local, setLocal] = useState ('')
useEffect (() => { useEffect (() => {
@@ -36,25 +35,13 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
return ( return (
<input <input
{...rest} {...rest}
className={cn ('border rounded p-2', className={cn ('border rounded p-2', className)}
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none',
'focus:ring-2 focus:ring-red-200',
'dark:border-red-500 dark:bg-red-950/30 dark:text-red-100']
: ['border-gray-300',
'focus:border-blue-500 focus:outline-none',
'focus:ring-2 focus:ring-blue-200']),
className)}
type="datetime-local" type="datetime-local"
value={local} value={local}
aria-invalid={invalid}
onChange={ev => { onChange={ev => {
const v = ev.target.value const v = ev.target.value
setLocal (v) setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null) onChange?.(v ? (new Date (v)).toISOString () : null)
}} }}
onBlur={onBlur}/>) onBlur={onBlur}/>)
} }) satisfies FC<Props>
export default DateTimeField
-18
ファイルの表示
@@ -1,18 +0,0 @@
import type { FC } from 'react'
type Props = { id?: string
messages?: string[] }
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
if (!(messages) || messages.length === 0)
return null
return (
<ul id={id} className="mt-1 space-y-1 text-red-700 dark:text-red-300">
{messages.map ((message, i) => <li key={i}>{message}</li>)}
</ul>)
}
export default FieldError
+2 -4
ファイルの表示
@@ -3,9 +3,7 @@ import type { FC, ReactNode } from 'react'
type Props = { children: ReactNode } type Props = { children: ReactNode }
const Form: FC<Props> = ({ children }) => ( export default (({ children }: Props) => (
<div className="max-w-xl mx-auto p-4 space-y-4"> <div className="max-w-xl mx-auto p-4 space-y-4">
{children} {children}
</div>) </div>)) satisfies FC<Props>
export default Form
-36
ファイルの表示
@@ -1,36 +0,0 @@
import { useId } from 'react'
import FieldError from '@/components/common/FieldError'
import Label from '@/components/common/Label'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
type FieldState = { describedBy?: string
invalid: boolean }
type Props = {
children: (state: FieldState) => ReactNode
checkBox?: { label: string
checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
className?: string
label: ReactNode
messages?: string[] }
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
const id = useId ()
const invalid = messages != null && messages.length > 0
const errorId = invalid ? `${ id }-error` : undefined
return (
<div className={cn (className)}>
<Label checkBox={checkBox} invalid={invalid}>{label}</Label>
{children ({ describedBy: errorId, invalid })}
<FieldError id={errorId} messages={messages}/>
</div>)
}
export default FormField
-26
ファイルの表示
@@ -1,26 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import Label from '@/components/common/Label'
describe ('Label', () => {
it ('renders a plain label', () => {
render (<Label></Label>)
expect (screen.getByText ('名前')).toBeInTheDocument ()
})
it ('renders and toggles the optional checkbox', () => {
const handleChange = vi.fn ()
render (
<Label checkBox={{ label: '不明', checked: false, onChange: handleChange }}>
</Label>,
)
fireEvent.click (screen.getByRole ('checkbox', { name: '不明' }))
expect (handleChange).toHaveBeenCalledTimes (1)
})
})
+14 -25
ファイルの表示
@@ -1,39 +1,28 @@
import React from 'react' import React from 'react'
import { cn } from '@/lib/utils'
import type { FC } from 'react'
type Props = { children: React.ReactNode type Props = { children: React.ReactNode
checkBox?: { label: string checkBox?: { label: string
checked: boolean checked: boolean
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } onChange: (event: React.ChangeEvent<HTMLInputElement>) => void } }
invalid?: boolean }
const Label: FC<Props> = ({ children, checkBox, invalid }: Props) => { export default ({ children, checkBox }: Props) => {
const labelClassName = cn ('block font-semibold mb-1',
invalid && 'text-red-700 dark:text-red-300')
if (!(checkBox)) if (!(checkBox))
{ {
return ( return (
<label className={labelClassName}> <label className="block font-semibold mb-1">
{children} {children}
</label>) </label>)
} }
return ( return (
<div className="flex gap-2 mb-1"> <div className="flex gap-2 mb-1">
<label className="flex-1 block font-semibold">{children}</label> <label className="flex-1 block font-semibold">{children}</label>
<label className="flex items-center block gap-1"> <label className="flex items-center block gap-1">
<input type="checkbox" <input type="checkbox"
checked={checkBox.checked} checked={checkBox.checked}
onChange={checkBox.onChange}/> onChange={checkBox.onChange}/>
{checkBox.label} {checkBox.label}
</label> </label>
</div>) </div>)
} }
export default Label
-15
ファイルの表示
@@ -1,15 +0,0 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import PageTitle from '@/components/common/PageTitle'
describe ('PageTitle', () => {
it ('renders children as a level 1 heading', () => {
render (<PageTitle>Test title</PageTitle>)
const heading = screen.getByRole ('heading', { level: 1 })
expect (heading.textContent).toBe ('Test title')
})
})
+1 -5
ファイルの表示
@@ -1,13 +1,9 @@
import React from 'react' import React from 'react'
import type { FC } from 'react'
type Props = { children: React.ReactNode } type Props = { children: React.ReactNode }
const PageTitle: FC<Props> = ({ children }) => ( export default ({ children }: Props) => (
<h1 className="text-2xl font-bold mb-2"> <h1 className="text-2xl font-bold mb-2">
{children} {children}
</h1>) </h1>)
export default PageTitle
-38
ファイルの表示
@@ -1,38 +0,0 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import Pagination from '@/components/common/Pagination'
import { renderWithProviders } from '@/test/render'
describe ('Pagination', () => {
it ('builds page links while preserving existing query parameters', () => {
renderWithProviders (
<Pagination page={3} totalPages={5} siblingCount={1}/>,
{ route: '/posts?tags=abc&page=3' },
)
expect (screen.getByLabelText ('前のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=2',
)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href',
'/posts?tags=abc&page=4',
)
expect (screen.getByText ('3')).toHaveAttribute ('aria-current', 'page')
})
it ('does not render active previous and next controls at the edges', () => {
const { rerender } = renderWithProviders (
<Pagination page={1} totalPages={1}/>,
{ route: '/tags' },
)
expect (screen.queryByLabelText ('前のページ')).not.toBeInTheDocument ()
expect (screen.queryByLabelText ('次のページ')).not.toBeInTheDocument ()
rerender (<Pagination page={1} totalPages={2}/>)
expect (screen.getByLabelText ('次のページ')).toHaveAttribute ('href', '/tags?page=2')
})
})
+2 -4
ファイルの表示
@@ -48,7 +48,7 @@ const getPages = (
} }
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => { export default (({ page, totalPages, siblingCount = 3 }) => {
const location = useLocation () const location = useLocation ()
const buildTo = (p: number) => { const buildTo = (p: number) => {
@@ -124,6 +124,4 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
</>)} </>)}
</div> </div>
</nav>) </nav>)
} }) satisfies FC<Props>
export default Pagination
+2 -4
ファイルの表示
@@ -5,9 +5,7 @@ import type { ComponentPropsWithoutRef, FC } from 'react'
type Props = ComponentPropsWithoutRef<'h2'> type Props = ComponentPropsWithoutRef<'h2'>
const SectionTitle: FC<Props> = ({ children, className, ...rest }) => ( export default (({ children, className, ...rest }: Props) => (
<h2 {...rest} className={cn ('text-xl my-4', className)}> <h2 {...rest} className={cn ('text-xl my-4', className)}>
{children} {children}
</h2>) </h2>)) satisfies FC<Props>
export default SectionTitle
+1 -5
ファイルの表示
@@ -1,13 +1,9 @@
import React from 'react' import React from 'react'
import type { FC } from 'react'
type Props = { children: React.ReactNode } type Props = { children: React.ReactNode }
const SubsectionTitle: FC<Props> = ({ children }) => ( export default ({ children }: Props) => (
<h3 className="my-2"> <h3 className="my-2">
{children} {children}
</h3>) </h3>)
export default SubsectionTitle
-23
ファイルの表示
@@ -1,23 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import TabGroup, { Tab } from '@/components/common/TabGroup'
describe ('TabGroup', () => {
it ('uses the init tab and switches tabs when clicked', () => {
render (
<TabGroup>
<Tab name="A">Alpha</Tab>
<Tab name="B" init>Beta</Tab>
</TabGroup>,
)
expect (screen.queryByText ('Alpha')).not.toBeInTheDocument ()
expect (screen.getByText ('Beta')).toBeInTheDocument ()
fireEvent.click (screen.getByText ('A'))
expect (screen.getByText ('Alpha')).toBeInTheDocument ()
expect (screen.queryByText ('Beta')).not.toBeInTheDocument ()
})
})
+1 -5
ファイルの表示
@@ -1,5 +1,3 @@
import type { FC } from 'react'
import React, { useState } from 'react' import React, { useState } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -12,7 +10,7 @@ type Props = { children: React.ReactNode }
export const Tab = ({ children }: TabProps) => <>{children}</> export const Tab = ({ children }: TabProps) => <>{children}</>
const TabGroup: FC<Props> = ({ children }) => { export default ({ children }: Props) => {
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> (() => {
@@ -39,5 +37,3 @@ const TabGroup: FC<Props> = ({ children }) => {
</div> </div>
</div>) </div>)
} }
export default TabGroup
-44
ファイルの表示
@@ -1,44 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TagInput from '@/components/common/TagInput'
import { buildTag } from '@/test/factories'
const api = vi.hoisted (() => ({
apiGet: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
describe ('TagInput', () => {
beforeEach (() => {
vi.clearAllMocks ()
})
it ('updates value and fetches autocomplete for the last token', async () => {
const setValue = vi.fn ()
api.apiGet.mockResolvedValueOnce ([buildTag ({ name: '虹夏', postCount: 2 })])
render (<TagInput value="ぼっち 虹" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: 'ぼっち 虹夏' } })
await waitFor (() => {
expect (api.apiGet).toHaveBeenCalledWith (
'/tags/autocomplete',
{ params: { q: '虹夏' } },
)
})
expect (setValue).toHaveBeenCalledWith ('ぼっち 虹夏')
})
it ('does not fetch when the last token is blank', () => {
const setValue = vi.fn ()
render (<TagInput value="" setValue={setValue}/>)
fireEvent.change (screen.getByRole ('textbox'), { target: { value: ' ' } })
expect (api.apiGet).not.toHaveBeenCalled ()
expect (setValue).toHaveBeenCalledWith (' ')
})
})

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