コミットを比較

..

14 コミット

作成者 SHA1 メッセージ 日付
みてるぞ 546a212e74 #302 2026-06-07 02:50:04 +09:00
みてるぞ 201fe72e5a #302 2026-06-07 02:40:05 +09:00
みてるぞ 6e338c8616 #302 2026-06-07 02:13:40 +09:00
みてるぞ be2df723fe #302 2026-06-07 02:01:16 +09:00
みてるぞ 39d86f4778 #302 2026-06-07 01:24:44 +09:00
みてるぞ 69820265fd #302 2026-06-07 00:41:35 +09:00
みてるぞ 4b26f017b4 #302 2026-06-07 00:26:18 +09:00
みてるぞ a50c29cc35 #302 2026-06-07 00:14:36 +09:00
みてるぞ 364d154b6a #302 2026-06-07 00:05:18 +09:00
みてるぞ b1362d327c #302 2026-06-06 20:29:34 +09:00
みてるぞ 81e620c33a #302 2026-06-06 19:49:21 +09:00
みてるぞ 62857adb87 Merge remote-tracking branch 'origin/main' into feature/302 2026-06-06 13:11:36 +09:00
みてるぞ 750aa40e8e フォームのバリデーションとニコ連携の画面変更 (#090) (#355)
Reviewed-on: #355
Co-authored-by: miteruzo <miteruzo@naver.com>
Co-committed-by: miteruzo <miteruzo@naver.com>
2026-06-05 01:59:46 +09:00
みてるぞ 09763982b5 #302 2026-05-17 21:09:43 +09:00
35個のファイルの変更2344行の追加305行の削除
+68 -21
ファイルの表示
@@ -12,16 +12,21 @@ 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`, `factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`, `aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`. - Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`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, Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and Zustand. - Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
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, wiki commit, YouTube sync, and similarity calculation. - `backend/app/services`: domain services such as version recording,
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,7 +94,8 @@ npm run test:run
npm run preview npm run preview
``` ```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`. `npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`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. `npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
@@ -98,40 +104,77 @@ npm run preview
- 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 double quotes better. - Prefer single quotes for strings unless interpolation or escaping makes
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`.
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid. - Ruby hashes are not blocks; keep `}` on the same line as the final pair.
- 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 editing backend behavior. - Inspect existing routes, controllers, models, services, and specs before
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 `backend/test` do not make minitest the default for new coverage. - Prefer RSpec for new backend tests; existing minitest files under
`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 explicitly changes authentication. - Preserve the `X-Transfer-Code` user identification flow unless the task
- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior. explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior. - 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`; avoid ad hoc query key arrays. - Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
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 under `frontend/src/components` unless existing patterns point elsewhere. - Keep page-level code under `frontend/src/pages` and shared UI/feature code
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 ### Frontend TSX style
- Preserve the local TSX formatting style. Do not normalize TSX to common Prettier-style React formatting unless explicitly asked. - 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. - 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. - Put two blank lines before and after top-level `const` function
- In TSX, indent nested tag attributes with one tab relative to the tag line. With the project tab width, this visually appears as 4 spaces. declarations, unless imports, exports, or file boundaries make that awkward.
- 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. - In TSX, indent with 4-space logical indentation.
- Keep JSX closing parentheses in the existing compact style, for example `</div>)` rather than moving `)` onto a separate line. - 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: Preferred:
@@ -164,10 +207,14 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- 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 `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. - Do not scan or summarize dependency/generated/runtime directories such as
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects. `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- If frontend code changes, run the existing frontend verification commands that apply: `npm run build`, `npm run lint`, and `npm run test:run`. - Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`. behavior, inspect the related request specs and service objects.
- 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
+90 -25
ファイルの表示
@@ -4,7 +4,9 @@
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, service objects, representation classes, and version tables for post/tag/wiki history. This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands ## Commands
@@ -50,32 +52,57 @@ 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, YouTube sync, and similarity calculation. - `app/services`: domain services such as version recorders, wiki commit,
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, service, representation, and spec. Before changing behavior, inspect the matching route, controller, model,
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 `ApplicationController#authenticate_user`. - Authentication is handled through the `X-Transfer-Code` header in
`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 explicitly changes authentication. - Do not bypass or weaken the `X-Transfer-Code` flow unless the task
- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers. explicitly changes authentication.
- 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 existing controllers do so. - Use `current_user.gte_member?` for member-or-admin write permissions where
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.
@@ -88,7 +115,8 @@ Before changing behavior, inspect the matching route, controller, model, service
- 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 update request specs covering banned users and banned IP addresses. - If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses.
## RSpec ## RSpec
@@ -99,49 +127,86 @@ Before changing behavior, inspect the matching route, controller, model, service
- 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 `ApplicationController#current_user`; use it when matching existing request spec style. - `AuthHelper#sign_in_as(user)` stubs
- Add or update request specs for API behavior changes, especially status codes, permissions, response shape, and version conflict behavior. `ApplicationController#current_user`; use it when matching existing
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 defining migration-local `ActiveRecord::Base` classes with `self.table_name`. - For data backfills inside migrations, follow the existing pattern of
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 explicitly requested; add a new migration. - Do not edit old migrations just to change current behavior unless
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 `version_no` with unique indexes scoped to the parent record. - Current records have `version_no`; version tables have positive
`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 rows in application code: - Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder` - `PostVersionRecorder`
- `TagVersionRecorder` - `TagVersionRecorder`
- `NicoTagVersionRecorder` - `NicoTagVersionRecorder`
- `WikiVersionRecorder` - `WikiVersionRecorder`
- `TagVersioning` - `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency, skips unchanged update snapshots, creates the next version row, and updates the record `version_no`. - `VersionRecorder` locks the current record, validates sequence consistency,
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 `merge` semantics and cover conflicts in request specs. - For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics and cover conflicts in request specs.
## Domain cautions ## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior. - Posts have tag snapshots, parent post implications, original-created ranges,
- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots. viewed state, and version conflict behavior.
- Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code. - Tags have canonical names, aliases through `TagName`, categories, parent
- Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths. implications, discard behavior, and version snapshots.
- Materials, theatres, and comments have user and permission checks; inspect the controller before changing them. - Nico tags have separate relation/version behavior; do not treat them like
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; frontend code expects camelCase conversion client-side, while Rails params and JSON keys are generally snake_case. - Keep response keys consistent with existing JSON contracts.
- 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. - Frontend code expects camelCase conversion client-side, while Rails params
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 directories unless explicitly needed. - Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
- Do not modify generated schema or migration output without the corresponding migration when schema changes are made. directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
+5 -3
ファイルの表示
@@ -44,7 +44,8 @@ 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, tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .preload(:uploaded_user, :parents, :children,
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
@@ -95,7 +96,7 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts.preload(:uploaded_user, post = filtered_posts.preload(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail
.order('RAND()') .order('RAND()')
@@ -108,7 +109,8 @@ class PostsController < ApplicationController
def show def show
post = post =
Post Post
.includes(:uploaded_user, tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .includes(:uploaded_user, :parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail
.find_by(id: params[:id]) .find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
+24 -2
ファイルの表示
@@ -1,14 +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.negative? no_gt = 0 if no_gt < 0
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.as_json(include: { user: { only: [:id, :name] } }) render json: comments.map {
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end end
def create def create
@@ -29,4 +36,19 @@ 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
ファイルの表示
@@ -0,0 +1,22 @@
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
ファイルの表示
@@ -0,0 +1,22 @@
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
+113 -8
ファイルの表示
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: { render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:)
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
@@ -43,12 +41,119 @@ 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
post = Post.where("url LIKE '%nicovideo.jp%'") ApplicationRecord.transaction do
.or(Post.where("url LIKE '%youtube.com%'")) theatre.lock!
.order('RAND()') TheatrePostAdvancer.call(theatre:)
.first end
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
+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.present? def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
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,6 +7,10 @@ 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
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
+10
ファイルの表示
@@ -0,0 +1,10 @@
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
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventTag < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :tag_id
belongs_to :theatre_skip_event
belongs_to :tag
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class TheatreSkipEventVoter < ApplicationRecord
self.primary_key = :theatre_skip_event_id, :user_id
belongs_to :theatre_skip_event
belongs_to :user
end
+7
ファイルの表示
@@ -0,0 +1,7 @@
class TheatreSkipVote < ApplicationRecord
self.primary_key = :theatre_id, :post_id, :user_id
belongs_to :theatre
belongs_to :post
belongs_to :user
end
+29
ファイルの表示
@@ -0,0 +1,29 @@
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
ファイルの表示
@@ -0,0 +1,119 @@
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
ファイルの表示
@@ -0,0 +1,40 @@
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
+6 -1
ファイルの表示
@@ -85,9 +85,14 @@ 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] resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
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
ファイルの表示
@@ -0,0 +1,10 @@
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
ファイルの表示
@@ -0,0 +1,36 @@
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
生成ファイル
+62 -1
ファイルの表示
@@ -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_05_07_213300) do ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) 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,6 +283,55 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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
@@ -464,6 +513,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) 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"
+60
ファイルの表示
@@ -80,6 +80,26 @@ 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
@@ -147,4 +167,44 @@ 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
ファイルの表示
@@ -0,0 +1,38 @@
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
+226 -8
ファイルの表示
@@ -14,10 +14,24 @@ 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=spec123' url: 'https://www.youtube.com/watch?v=yt123'
) )
end end
@@ -120,7 +134,8 @@ 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(
@@ -177,7 +192,8 @@ 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(
@@ -204,7 +220,7 @@ RSpec.describe 'Theatres API', type: :request do
) )
theatre.update!( theatre.update!(
host_user: other_user, host_user: other_user,
current_post: youtube_post, current_post: niconico_post,
current_post_started_at: started_at current_post_started_at: started_at
) )
sign_in_as(member) sign_in_as(member)
@@ -220,9 +236,11 @@ 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(youtube_post.id) expect(json['post_id']).to eq(niconico_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
@@ -273,16 +291,36 @@ 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,
@@ -299,9 +337,189 @@ 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) expect(theatre.current_post_started_at).to be_nil
.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
+68 -16
ファイルの表示
@@ -4,7 +4,8 @@
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, Framer Motion, Radix UI-style components, MDX, and Zustand. This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands ## Commands
@@ -17,9 +18,11 @@ npm run lint
npm run preview npm run preview
``` ```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`. `npm run build` runs `tsc -b && vite build`, and `postbuild` runs
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added. There is currently no `test` script in `package.json`. Do not run or report
`npm test` unless a test script is added.
After frontend changes, run: After frontend changes, run:
@@ -32,18 +35,37 @@ If either command cannot be run or fails, report the exact command and failure.
## TypeScript ## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`. - TypeScript is strict. `tsconfig.app.json` enables `strict`,
`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 GNU-style spacing before call parentheses where it is already used. - Preserve the repository's existing spacing style in TypeScript, including
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 `FC`; match nearby file style when editing. - Existing page components commonly export an anonymous function satisfying
`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`.
@@ -52,17 +74,23 @@ 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 instead of using ad hoc arrays in components. - Query keys should come from `src/lib/queryKeys.ts`; add key builders there
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`. instead of using ad hoc arrays in components.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views. - Fetch functions should live in domain helpers under `src/lib`, such as
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code. `posts.ts`, `tags.ts`, or `wiki.ts`.
- 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 non-blob responses to camelCase. - The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
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 third-party request outside the Rails API. - Do not bypass the API wrapper unless there is a specific reason, such as a
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
@@ -76,17 +104,41 @@ 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 `src/components/ui` before adding new primitives. - Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components. - Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected. - Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
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`, and `eslint-plugin-react-refresh`. - ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
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 errors, not lint-only issues. - Build failures from unused locals or unused parameters are TypeScript
errors, not lint-only issues.
## Files to avoid in routine work ## Files to avoid in routine work
+1 -1
ファイルの表示
@@ -64,7 +64,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<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="/nico/tags" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<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/>}/>
+49 -8
ファイルの表示
@@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from '
type NiconicoPlayerMessage = type NiconicoPlayerMessage =
| { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'enterProgrammaticFullScreen' }
| { eventName: 'exitProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' }
| { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } } | { eventName: 'loadComplete'
| { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata } playerId?: string
| { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown } data: { videoInfo: NiconicoVideoInfo } }
| { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string } | { eventName: 'playerMetadataChange'
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 }
@@ -30,6 +40,7 @@ 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
@@ -37,14 +48,18 @@ 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 } = props const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props
const iframeRef = useRef<HTMLIFrameElement> (null) const iframeRef = useRef<HTMLIFrameElement> (null)
const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) const loadCompleteTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
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,6 +94,24 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
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))
@@ -162,6 +195,7 @@ 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
} }
@@ -173,13 +207,19 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{
clearLoadCompleteTimer ()
console.error ('niconico player error:', data) console.error ('niconico player error:', data)
onError?.(data)
}
} }
addEventListener ('message', onMessage) addEventListener ('message', onMessage)
return () => removeEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage)
}, [onLoadComplete, onMetadataChange, playerId]) }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId])
useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
@@ -234,6 +274,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
width={width} width={width}
height={height} height={height}
style={margedStyle} style={margedStyle}
onLoad={startLoadCompleteTimer}
allowFullScreen allowFullScreen
allow="autoplay"/>) allow="autoplay"/>)
}) })
+90 -6
ファイルの表示
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import YoutubeEmbed from 'react-youtube' import YoutubeEmbed from 'react-youtube'
import NicoViewer from '@/components/NicoViewer' import NicoViewer from '@/components/NicoViewer'
@@ -8,17 +8,97 @@ 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> = ({ ref, post, onLoadComplete, onMetadataChange }) => { const PostEmbed: FC<Props> = ({
ref,
post,
onLoadComplete,
onMetadataChange,
onVideoReady,
onPlaybackChange,
onError,
}) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
const [youtubePlayer, setYoutubePlayer] = useState<YouTubePlayer | null> (null)
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) => {
onVideoReady?.(info.lengthInSeconds * 1_000)
onLoadComplete?.(info)
}
const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => {
onPlaybackChange?.(meta.currentTime)
onMetadataChange?.(meta)
}
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)
@@ -38,8 +118,9 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
id={videoId} id={videoId}
width={640} width={640}
height={360} height={360}
onLoadComplete={onLoadComplete} onLoadComplete={handleNiconicoLoadComplete}
onMetadataChange={onMetadataChange}/>) onMetadataChange={handleNiconicoMetadataChange}
onError={onError}/>)
} }
case 'twitter.com': case 'twitter.com':
@@ -69,7 +150,10 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
mute: 0, mute: 0,
loop: 1, loop: 1,
width: '640', width: '640',
height: '360' } }}/>) height: '360' } }}
onReady={handleYoutubeReady}
onStateChange={handleYoutubeStateChange}
onError={handleYoutubeError}/>)
} }
} }
+10 -10
ファイルの表示
@@ -55,12 +55,6 @@ 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' },
@@ -71,6 +65,8 @@ 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 },
@@ -132,8 +128,12 @@ 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)
@@ -244,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => {
<motion.div <motion.div
key="submenu-shell" key="submenu-shell"
layout layout
className="relative hidden md:block overflow-hidden className="relative z-20 hidden md:block overflow-hidden
bg-yellow-200 dark:bg-red-950" bg-yellow-200 dark:bg-red-950"
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
setMoreVsbl (false) setMoreVsbl (false)
@@ -257,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => {
}}> }}>
{moreVsbl {moreVsbl
? ( ? (
menu.map ((item, i) => ( moreMenu.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-24"> className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
{item.subMenu {item.subMenu
+6 -3
ファイルの表示
@@ -64,11 +64,14 @@ export const apiPatch = async <T> (
): Promise<T> => apiP ('patch', path, body, opt) ): Promise<T> => apiP ('patch', path, body, opt)
export const apiDelete = async ( export const apiDelete = async <T = void> (
path: string, path: string,
opt?: Opt, opt?: Opt,
): Promise<void> => { ): Promise<T> => {
await client.delete (path, withUserCode (opt)) const res = await client.delete (path, withUserCode (opt))
if (res.data == null || res.data === '')
return undefined as T
return toCamel (res.data as Record<string, unknown>, { deep: true }) as T
} }
+1
ファイルの表示
@@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types'
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member'] const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = ( export const canEditContent = (
user: Pick<User, 'role'> | null | undefined, user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role) ): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+204
ファイルの表示
@@ -0,0 +1,204 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
import { buildPost,
buildTheatre,
buildTheatreComment,
buildTheatreInfo,
buildTheatrePostSelectionWeights,
buildTheatreProgramme,
buildUser } from '@/test/factories'
import { renderWithProviders } from '@/test/render'
import type { ReactNode } from 'react'
const api = vi.hoisted (() => ({
apiDelete: vi.fn (),
apiGet: vi.fn (),
apiPatch: vi.fn (),
apiPost: vi.fn (),
apiPut: vi.fn (),
isApiError: vi.fn (() => false),
}))
const postsApi = vi.hoisted (() => ({
fetchPost: vi.fn (),
}))
const dialogue = vi.hoisted (() => ({
confirm: vi.fn (),
}))
vi.mock ('@/lib/api', () => api)
vi.mock ('@/lib/posts', () => postsApi)
vi.mock ('@/components/dialogues/DialogueProvider', () => ({
useDialogue: () => dialogue,
}))
vi.mock ('@/components/PostEmbed', () => ({
default: ({ post }: {
post: { title: string | null; url: string }
}) => <div>Embed:{post.title || post.url}</div>,
}))
vi.mock ('@/components/PostEditForm', () => ({
default: () => <div>Post edit form</div>,
}))
vi.mock ('framer-motion', () => ({
motion: {
aside: ({ children }: { children?: ReactNode }) => <aside>{children}</aside>,
div: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
main: ({ children }: { children?: ReactNode }) => <main>{children}</main>,
},
}))
const currentPost = buildPost ({
id: 10,
title: '上映中の投稿',
url: 'https://www.nicovideo.jp/watch/sm10',
})
const theatre = buildTheatre ({ id: 7, name: '上映室' })
const programme = buildTheatreProgramme ({
theatreId: 7,
position: 3,
post: currentPost,
})
const weights = buildTheatrePostSelectionWeights ({
lightestPosts: [{
post: currentPost,
penalty: 2,
weight: 0.5,
tags: [],
}],
})
const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
renderWithProviders (
<Routes>
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
</Routes>,
{ route: '/theatres/7' },
)
const mockDefaultApi = () => {
api.apiGet.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7':
return Promise.resolve (theatre)
case '/theatres/7/comments':
return Promise.resolve ([
buildTheatreComment ({
theatreId: 7,
no: 2,
user: { id: 1, name: 'tester' },
content: '視聴コメント',
}),
])
case '/theatres/7/programmes':
return Promise.resolve ([programme])
case '/theatres/7/post_selection_weights':
return Promise.resolve (weights)
default:
return Promise.reject (new Error (`Unexpected GET ${ path }`))
}
})
api.apiPut.mockImplementation ((path: string) => {
switch (path)
{
case '/theatres/7/watching':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 1_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 0,
requiredCount: 2,
watchingUsersCount: 1,
voted: false,
},
}))
case '/theatres/7/skip_vote':
return Promise.resolve (buildTheatreInfo ({
postId: currentPost.id,
postStartedAt: '2026-01-02T03:04:05.000Z',
postElapsedMs: 2_000,
watchingUsers: [{ id: 1, name: 'tester' }],
skipVote: {
votesCount: 1,
requiredCount: 2,
watchingUsersCount: 1,
voted: true,
},
}))
default:
return Promise.reject (new Error (`Unexpected PUT ${ path }`))
}
})
api.apiDelete.mockResolvedValue (undefined)
api.apiPatch.mockResolvedValue (undefined)
api.apiPost.mockResolvedValue (undefined)
postsApi.fetchPost.mockResolvedValue (currentPost)
dialogue.confirm.mockResolvedValue (true)
}
describe ('TheatreDetailPage', () => {
beforeEach (() => {
vi.clearAllMocks ()
mockDefaultApi ()
})
it ('loads theatre state, comments, current post, programme history, and weights', async () => {
renderPage ()
expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument ()
expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument ()
expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument ()
expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument ()
expect (screen.getByText ('penalty 2')).toBeInTheDocument ()
await waitFor (() => {
expect (postsApi.fetchPost).toHaveBeenCalledWith ('10')
})
})
it ('votes to skip the current post', async () => {
renderPage ()
await screen.findByText ('Embed:上映中の投稿')
fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' }))
await waitFor (() => {
expect (api.apiPut).toHaveBeenCalledWith (
'/theatres/7/skip_vote',
{ post_id: 10 },
)
})
expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' }))
.toBeInTheDocument ()
})
it ('deletes an owned comment after confirmation', async () => {
renderPage ()
fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0])
await waitFor (() => {
expect (dialogue.confirm).toHaveBeenCalled ()
})
await waitFor (() => {
expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2')
})
expect (await screen.findAllByText ('削除されました.')).toHaveLength (2)
})
})
+709 -114
ファイルの表示
@@ -1,65 +1,271 @@
import { useEffect, useRef, useState } from 'react' import { motion } from 'framer-motion'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import ErrorScreen from '@/components/ErrorScreen' import ErrorScreen from '@/components/ErrorScreen'
import PostEditForm from '@/components/PostEditForm'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagDetailSidebar from '@/components/TagDetailSidebar' import TagLink from '@/components/TagLink'
import FieldError from '@/components/common/FieldError' import FieldError from '@/components/common/FieldError'
import MainArea from '@/components/layout/MainArea' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import SidebarComponent from '@/components/layout/SidebarComponent' import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import { dateString, inputClass } from '@/lib/utils' import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC } from 'react' import type { FC, FormEvent, ReactNode } from 'react'
import type { NiconicoMetadata, import type { NiconicoViewerHandle,
NiconicoViewerHandle,
Post, Post,
Category,
Tag,
Theatre, Theatre,
TheatreComment } from '@/types' TheatreComment,
TheatreInfo,
type TheatreInfo = { TheatrePostSelectionWeights,
hostFlg: boolean TheatreProgramme,
postId: number | null User } from '@/types'
postStartedAt: string | null
watchingUsers: { id: number; name: string }[] }
type TheatreCommentField = 'content' type TheatreCommentField = 'content'
type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
type TagFlow = 'vertical' | 'horizontal'
const INITIAL_THEATRE_INFO = const INITIAL_THEATRE_INFO: TheatreInfo =
{ hostFlg: false, { hostFlg: false,
postId: null, postId: null,
postStartedAt: null, postStartedAt: null,
watchingUsers: [] as { id: number; name: string }[] } as const postElapsedMs: null,
watchingUsers: [],
skipVote: { votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false } }
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
tagsBottom: '2 列 A 型',
commentsBottom: '2 列 B 型' }
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
vertical: '縦並び',
horizontal: '横並び' }
const TheatreDetailPage: FC = () => { const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
user ? (user.name || `名もなきニジラー(#${ user.id }`) : '運営'
const commentBox = (
comment: TheatreComment,
programme: TheatreProgramme | null = null,
): ReactNode[] =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
? <span className="text-sm font-bold"></span>
: comment.content}
</div>),
(
<div key={`${ comment.no }-user`} className="w-full text-sm text-right">
by {userName (comment.user)}
</div>),
(
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
{dateString (comment.createdAt)}
</div>),
(
<div
key={`${ comment.no }-post`}
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
&thinsp;
</>)}
</div>)]
const compareTagName = (a: Tag, b: Tag): number =>
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
const grouped: Partial<Record<Category, Tag[]>> = { }
for (const tag of tags)
{
grouped[tag.category] ??= []
grouped[tag.category]!.push (tag)
}
for (const cat of CATEGORIES)
grouped[cat]?.sort (compareTagName)
return grouped
}
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{ tags, compact, flow = 'vertical' },
) => {
const grouped = tagsByCategory (tags)
if (flow === 'horizontal')
{
return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul>)
}
return (
<div className="space-y-3">
{CATEGORIES.map (cat => {
const rows = grouped[cat] ?? []
if (rows.length === 0)
return null
return (
<div key={cat}>
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
{CATEGORY_NAMES[cat]}
</div>
<ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul>
</div>)
})}
</div>)
}
type Props = { user: User | null }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const { id } = useParams () const { id } = useParams ()
const dialogue = useDialogue ()
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const theatreInfoReceivedAtRef = useRef (performance.now ())
const videoLengthRef = useRef (0) const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0) const lastCommentNoRef = useRef (0)
const [comments, setComments] = useState<TheatreComment[]> ([]) const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('') const [content, setContent] = useState ('')
const [editingPost, setEditingPost] = useState<Post | null> (null)
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
const [sending, setSending] = useState (false) const [sending, setSending] = useState (false)
const [status, setStatus] = useState (200) const [status, setStatus] = useState (200)
const [theatre, setTheatre] = useState<Theatre | null> (null) const [theatre, setTheatre] = useState<Theatre | null> (null)
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
const [post, setPost] = useState<Post | null> (null) const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0) const [videoLength, setVideoLength] = useState (0)
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
return (
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode))
? (stored as TheatreLayoutMode)
: 'threeColumns')
})
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
return (
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? (stored as TagFlow)
: 'vertical')
})
const { fieldErrors, clearValidationErrors, applyValidationError } = const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> () useValidationErrors<TheatreCommentField> ()
const changeLayoutMode = (mode: TheatreLayoutMode) => {
setLayoutMode (mode)
localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
}
const changeTagFlow = (flow: TagFlow) => {
setTagFlow (flow)
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
}
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
theatreInfoReceivedAtRef.current = performance.now ()
setTheatreInfo (nextInfo)
}, [])
const currentPostElapsedMs = useCallback (
(info: TheatreInfo = theatreInfoRef.current): number => {
if (info.postElapsedMs == null)
return 0
return Math.max (
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
0)
}, [])
const refreshProgrammes = useCallback (async () => {
if (!(id))
return
setProgrammes (await apiGet<TheatreProgramme[]> (
`/theatres/${ id }/programmes`, { params: { limit: '100' } }))
}, [id])
const refreshWeights = useCallback (async () => {
if (!(id))
return
setWeights (await apiGet<TheatrePostSelectionWeights> (
`/theatres/${ id }/post_selection_weights`))
}, [id])
const advancePost = useCallback (async () => {
if (!(id))
return
setLoading (true)
try
{
await apiPatch<void> (`/theatres/${ id }/next_post`)
await refreshProgrammes ()
await refreshWeights ()
}
catch (error)
{
console.error (error)
}
finally
{
setLoading (false)
}
}, [id, refreshProgrammes, refreshWeights])
useEffect (() => { useEffect (() => {
loadingRef.current = loading loadingRef.current = loading
}, [loading]) }, [loading])
@@ -83,10 +289,14 @@ const TheatreDetailPage: FC = () => {
let cancelled = false let cancelled = false
setComments ([]) setComments ([])
setTheatre (null) setEditingPost (null)
setPost (null) setPost (null)
setProgrammes ([])
setTheatre (null)
theatreInfoReceivedAtRef.current = performance.now ()
setTheatreInfo (INITIAL_THEATRE_INFO) setTheatreInfo (INITIAL_THEATRE_INFO)
setVideoLength (0) setVideoLength (0)
setWeights (INITIAL_WEIGHTS)
lastCommentNoRef.current = 0 lastCommentNoRef.current = 0
void (async () => { void (async () => {
@@ -102,10 +312,13 @@ const TheatreDetailPage: FC = () => {
} }
}) () }) ()
void refreshProgrammes ()
void refreshWeights ()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id]) }, [id, refreshProgrammes, refreshWeights])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
@@ -124,11 +337,11 @@ const TheatreDetailPage: FC = () => {
{ {
const newComments = await apiGet<TheatreComment[]> ( const newComments = await apiGet<TheatreComment[]> (
`/theatres/${ id }/comments`, `/theatres/${ id }/comments`,
{ params: { no_gt: lastCommentNoRef.current } }) { params: { no_gt: lastCommentNoRef.current, limit: '20' } })
if (!(cancelled) && newComments.length > 0) if (!(cancelled) && newComments.length > 0)
{ {
lastCommentNoRef.current = newComments[newComments.length - 1].no lastCommentNoRef.current = newComments[0].no
setComments (prev => [...newComments, ...prev]) setComments (prev => [...newComments, ...prev])
} }
@@ -136,20 +349,26 @@ const TheatreDetailPage: FC = () => {
const ended = const ended =
currentInfo.hostFlg currentInfo.hostFlg
&& currentInfo.postStartedAt && currentInfo.postStartedAt
&& ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ()) && videoLengthRef.current > 0
> videoLengthRef.current + 3_000) && currentPostElapsedMs (currentInfo) > videoLengthRef.current + 3_000
if (ended) if (ended)
{ {
if (!(cancelled)) if (!(cancelled))
setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null })) {
setTheatreInfo (prev => ({
...prev,
postId: null,
postStartedAt: null,
postElapsedMs: null }))
}
return return
} }
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`) const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
if (!(cancelled)) if (!(cancelled))
setTheatreInfo (nextInfo) applyTheatreInfo (nextInfo)
} }
catch (error) catch (error)
{ {
@@ -168,7 +387,7 @@ const TheatreDetailPage: FC = () => {
cancelled = true cancelled = true
clearInterval (interval) clearInterval (interval)
} }
}, [id]) }, [applyTheatreInfo, currentPostElapsedMs, id])
useEffect (() => { useEffect (() => {
if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null)
@@ -177,33 +396,24 @@ const TheatreDetailPage: FC = () => {
let cancelled = false let cancelled = false
void (async () => { void (async () => {
setLoading (true) await advancePost ()
if (cancelled)
try
{
await apiPatch<void> (`/theatres/${ id }/next_post`)
}
catch (error)
{
console.error (error)
}
finally
{
if (!(cancelled))
setLoading (false) setLoading (false)
}
}) () }) ()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id, theatreInfo.hostFlg, theatreInfo.postId]) }, [advancePost, id, theatreInfo.hostFlg, theatreInfo.postId])
useEffect (() => { useEffect (() => {
setVideoLength (0) setVideoLength (0)
if (theatreInfo.postId == null) if (theatreInfo.postId == null)
{
setPost (null)
return return
}
let cancelled = false let cancelled = false
@@ -225,63 +435,113 @@ const TheatreDetailPage: FC = () => {
} }
}, [theatreInfo.postId]) }, [theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => { useEffect (() => {
void refreshProgrammes ()
}, [refreshProgrammes, theatreInfo.postId])
const syncPlaybackTime = (currentTimeMs: number): number | void => {
if (!(theatreInfo.postStartedAt)) if (!(theatreInfo.postStartedAt))
return return
const targetTime = Math.min ( const targetTime = Math.min (
Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), currentPostElapsedMs (theatreInfo),
videoLength) videoLength)
const drift = Math.abs (meta.currentTime - targetTime) const drift = Math.abs (currentTimeMs - targetTime)
if (drift > 5_000) if (drift > 5_000)
embedRef.current?.seek (targetTime) embedRef.current?.seek (targetTime)
return targetTime
} }
if (status >= 400) const handlePlaybackError = async () => {
return <ErrorScreen status={status}/> if (!(theatreInfoRef.current.hostFlg) || loadingRef.current)
return
return ( loadingRef.current = true
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> try
<Helmet> {
{theatre && ( await advancePost ()
<title> }
{'上映会場' finally
+ (theatre.name ? `${ theatre.name }` : ` #${ theatre.id }`) {
+ ` | ${ SITE_TITLE }`} loadingRef.current = false
</title>)} }
</Helmet> }
<div className="hidden md:block"> const handleVideoReady = (durationMs: number) => {
{post && <TagDetailSidebar post={post}/>} const playableDurationMs =
</div> Number.isFinite (durationMs)
? durationMs
: 0
setVideoLength (playableDurationMs)
if (playableDurationMs <= 0)
{
void handlePlaybackError ()
return
}
<MainArea>
{post ? (
<>
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={info => {
embedRef.current?.play () embedRef.current?.play ()
setVideoLength (info.lengthInSeconds * 1_000) }
}}
onMetadataChange={syncPlayback}/>
<div className="m-2">
<></>
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold">
{post.title || post.url}
</PrefetchLink>
</div>
</>) : 'Loading...'}
</MainArea>
<SidebarComponent> const handleSkipVote = async () => {
<form if (!(id) || !(post))
className="w-auto h-auto border border-black dark:border-white rounded mx-2" return
onSubmit={async e => {
setLoading (true)
try
{
const nextInfo =
theatreInfo.skipVote.voted
? await apiDelete<TheatreInfo> (
`/theatres/${ id }/skip_vote`, { params: { post_id: post.id } })
: await apiPut<TheatreInfo> (
`/theatres/${ id }/skip_vote`, { post_id: post.id })
applyTheatreInfo (nextInfo)
if (nextInfo.skipped)
{
setPost (null)
await refreshProgrammes ()
await refreshWeights ()
}
}
catch (error)
{
if (isApiError (error) && error.response?.status === 409)
applyTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
console.error (error)
}
finally
{
setLoading (false)
}
}
const handleDelete = async (commentNo: number) => {
try
{
await apiDelete (`/theatres/${ id }/comments/${ commentNo }`)
setComments (prev => {
const rtn = [...prev]
const idx = rtn.findIndex (x => x.no === commentNo)
if (idx >= 0)
rtn[idx] = { ...rtn[idx], deleted: true, content: null }
return rtn
})
}
catch
{
;
}
}
const handleCommentSubmit = async (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
if (!(content)) if (!(content))
@@ -303,7 +563,61 @@ const TheatreDetailPage: FC = () => {
{ {
setSending (false) setSending (false)
} }
}}> }
const skipVote = theatreInfo.skipVote
const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }` : '上映会場'
const postTags = post?.tags ?? []
const programmesAsc = useMemo (
() => [...programmes].sort (
(a, b) => Date.parse (a.createdAt) - Date.parse (b.createdAt)),
[programmes])
const programmeForComment = useCallback ((comment: TheatreComment): TheatreProgramme | null => {
const commentedAt = Date.parse (comment.createdAt)
let found: TheatreProgramme | null = null
for (const programme of programmesAsc)
{
const startedAt = Date.parse (programme.createdAt)
if (startedAt > commentedAt)
break
found = programme
}
return found
}, [programmesAsc])
if (status >= 400)
return <ErrorScreen status={status}/>
const tagPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold"></h2>
{layoutMode === 'tagsBottom' && (
<div className="hidden gap-2 md:flex">
{(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => (
<Button
key={flow}
type="button"
size="sm"
variant={tagFlow === flow ? 'default' : 'outline'}
onClick={() => changeTagFlow (flow)}>
{TAG_FLOW_LABELS[flow]}
</Button>))}
</div>)}
</div>
{postTags.length === 0
? <div className="text-sm text-zinc-500"></div>
: <TagList tags={postTags} flow={layoutMode === 'tagsBottom' ? tagFlow : 'vertical'}/>}
</section>)
const commentsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold"></h2>
<form onSubmit={handleCommentSubmit}>
<input <input
className={inputClass ((fieldErrors.content ?? []).length > 0)} className={inputClass ((fieldErrors.content ?? []).length > 0)}
type="text" type="text"
@@ -312,49 +626,330 @@ const TheatreDetailPage: FC = () => {
onChange={e => setContent (e.target.value)} onChange={e => setContent (e.target.value)}
disabled={sending}/> disabled={sending}/>
<FieldError messages={fieldErrors.content}/> <FieldError messages={fieldErrors.content}/>
</form>
<div <div
ref={commentsRef} ref={commentsRef}
className="overflow-x-hidden overflow-y-scroll text-wrap w-full className="mt-3 max-h-[48vh] overflow-y-auto rounded border border-zinc-200
h-[32vh] md:h-[64vh] border rounded"> dark:border-zinc-800">
{comments.map (comment => ( {comments.map (comment => {
<div key={comment.no} className="p-2"> const commentProgramme = programmeForComment (comment)
<div className="w-full"> return (
{comment.content} <div
</div> key={comment.no}
<div className="w-full text-sm text-right"> className="group relative border-t border-zinc-100 p-2 first:border-t-0
by {comment.user hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
? (comment.user.name || `名もなきニジラー(#${ comment.user.id }`) {(user && comment.user?.id === user.id && !(comment.deleted)) && (
: '運営'} <button
</div> type="button"
<div className="w-full text-sm text-right"> className="absolute left-1 top-1 hidden rounded px-1 text-red-600
{dateString (comment.createdAt)} hover:bg-red-100 group-hover:inline-block dark:text-red-300
dark:hover:bg-red-950"
aria-label="コメントを削除"
onClick={async e => {
e.stopPropagation ()
if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?',
description: (
<div className="my-3 w-120 rounded border border-black p-2
dark:border-white">
{commentBox (comment, commentProgramme)}
</div>),
confirmText: '削除',
variant: 'danger' })))
return
await handleDelete (comment.no)
}}>
&times;
</button>)}
{commentBox (comment, commentProgramme)}
</div>)
})}
</div> </div>
</section>)
const participantsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold"></h2>
<div className="space-y-1">
{theatreInfo.watchingUsers.map (watchingUser => (
<div key={watchingUser.id} className="flex justify-between gap-2 text-sm">
<span>{userName (watchingUser)}</span>
{watchingUser.id === user?.id && <span className="text-zinc-500"></span>}
</div>))} </div>))}
</div> </div>
</form> </section>)
<div className="w-auto h-auto border border-black dark:border-white rounded mx-2 mt-4"> const historyPanel = (
<div className="p-2"> <section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
{theatreInfo.watchingUsers.length} <h2 className="mb-3 font-bold"></h2>
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
overflow-y-auto">
{programmes.length === 0
? <div className="text-sm text-zinc-500"></div>
: (
programmes.map (programme => (
<div
key={`${ programme.theatreId }-${ programme.position }`}
className="border-zinc-100 p-2 text-sm first:border-t-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ programme.post.id }`}
className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
<div className="text-xs text-zinc-500">
{dateString (programme.createdAt)}
</div>
</div>)))}
</div>
</section>)
const weightsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold"></h2>
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
</Button>
</div> </div>
<div className="overflow-x-hidden overflow-y-scroll text-wrap w-full h-32 <div className="mx-4 grid gap-16 xl:grid-cols-3">
border rounded"> <div>
<ul className="list-inside list-disc"> <h3 className="mb-2 text-sm font-bold"></h3>
{theatreInfo.watchingUsers.map (user => ( <div className="space-y-1 text-sm">
<li key={user.id} className="px-4 py-1 text-sm"> {weights.tagPenalties.length === 0
{user.name || `名もなきニジラー(#${ user.id }`} ? <div className="text-zinc-500"></div>
</li>))} : (
</ul> weights.tagPenalties.slice (0, 12).map (row => (
<div
key={row.tag.id}
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2
text-left">
<div className="min-w-0 text-left">
<TagLink tag={row.tag} withCount={false}/>
</div>
<span className="font-mono">{row.penalty}</span>
</div>)))}
</div> </div>
</div> </div>
</SidebarComponent>
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<WeightRows rows={weights.lightestPosts}/>
</div>
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<WeightRows rows={weights.heaviestPosts}/>
</div>
</div>
</section>)
return (
<motion.div
layout="position"
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
<meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
</Helmet>
<div className={cn (
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
(layoutMode === 'threeColumns'
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
{layoutMode !== 'tagsBottom' && (
<motion.aside
layout="position"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
md:[direction:rtl]">
<div className="md:[direction:ltr]">
{tagPanel}
</div>
</motion.aside>)}
<motion.main
layout="position"
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3
border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div>
<h1 className="text-lg font-bold">{theatreTitle}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{theatreInfo.watchingUsers.length}
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="hidden flex-wrap gap-2 md:flex">
{(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => (
<Button
key={mode}
type="button"
size="sm"
variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]}
</Button>))}
</div>
<Button
type="button"
size="sm"
variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)}
onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button>
</div>
</div>
<div className="flex justify-center bg-black mx-4">
{post ? (
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onVideoReady={handleVideoReady}
onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
</div>)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
</div>
{post ? (
<PrefetchLink
to={`/posts/${ post.id }`}
className="font-bold hover:underline">
{post.title || post.url}
</PrefetchLink>) : (
<span className="text-zinc-500"></span>)}
</div>
{(post && canEditContent (user)) && (
<Button
type="button"
size="sm"
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
{editingPost ? '閉じる' : '編輯'}
</Button>)}
</div>
</section>
{editingPost && (
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3">
<h2 className="font-bold">稿</h2>
<p className="text-sm text-amber-900 dark:text-amber-100">
<PrefetchLink
to={`/posts/${ editingPost.id }`}
className="mx-1 font-bold underline">
{editingPost.title || editingPost.url}
</PrefetchLink>
</p>
</div>
<PostEditForm
post={editingPost}
onSave={newPost => {
setEditingPost (newPost)
if (post?.id === newPost.id)
setPost (newPost)
void refreshWeights ()
}}/>
</section>)}
<div className="md:hidden"> <div className="md:hidden">
{post && <TagDetailSidebar post={post} sp/>} {commentsPanel}
</div> </div>
</div>)
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{commentsPanel}
</div>)}
<div className="md:hidden">
{tagPanel}
</div>
{layoutMode === 'tagsBottom' && (
<div className="hidden md:block">
{tagPanel}
</div>)}
{historyPanel}
{weightsPanel}
<div className="md:hidden">
{participantsPanel}
</div>
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{participantsPanel}
</div>)}
</div>
</motion.main>
{layoutMode !== 'commentsBottom' && (
<motion.aside
layout="position"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
{commentsPanel}
{participantsPanel}
</motion.aside>)}
</div>
</motion.div>)
} }
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
<div className="space-y-2 text-sm">
{rows.length === 0
? <div className="text-zinc-500"></div>
: (
rows.slice (0, 8).map (row => (
<div
key={row.post.id}
className="border-zinc-100 pt-2 first:border-t-0 first:pt-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ row.post.id }`}
className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url}
</PrefetchLink>
<div className="flex justify-between gap-2 text-xs text-zinc-500">
<span>penalty {row.penalty}</span>
<span>weight {row.weight.toFixed (3)}</span>
</div>
</div>)))}
</div>)
export default TheatreDetailPage export default TheatreDetailPage
+69 -1
ファイルの表示
@@ -1,4 +1,13 @@
import type { Material, Post, Tag, User, WikiPage } from '@/types' import type { Material,
Post,
Tag,
Theatre,
TheatreComment,
TheatreInfo,
TheatrePostSelectionWeights,
TheatreProgramme,
User,
WikiPage } from '@/types'
export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 1, id: 1,
@@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial<Material> = {}): Material => ({
updatedByUser: { id: 2, name: 'updater' }, updatedByUser: { id: 2, name: 'updater' },
...overrides, ...overrides,
}) })
export const buildTheatre = (overrides: Partial<Theatre> = {}): Theatre => ({
id: 1,
name: 'テスト劇場',
opensAt: '2026-01-02T03:04:05.000Z',
closesAt: null,
createdByUser: { id: 1, name: 'creator' },
createdAt: '2026-01-02T03:04:05.000Z',
updatedAt: '2026-01-03T03:04:05.000Z',
...overrides,
})
export const buildTheatreInfo = (
overrides: Partial<TheatreInfo> = {},
): TheatreInfo => ({
hostFlg: false,
postId: null,
postStartedAt: null,
postElapsedMs: null,
watchingUsers: [],
skipVote: {
votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false,
},
...overrides,
})
export const buildTheatreComment = (
overrides: Partial<TheatreComment> = {},
): TheatreComment => ({
theatreId: 1,
no: 1,
deleted: false,
user: { id: 1, name: 'tester' },
content: 'テストコメント',
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
} as TheatreComment)
export const buildTheatreProgramme = (
overrides: Partial<TheatreProgramme> = {},
): TheatreProgramme => ({
theatreId: 1,
position: 1,
post: buildPost (),
createdAt: '2026-01-02T03:04:05.000Z',
...overrides,
})
export const buildTheatrePostSelectionWeights = (
overrides: Partial<TheatrePostSelectionWeights> = {},
): TheatrePostSelectionWeights => ({
tagPenalties: [],
lightestPosts: [],
heaviestPosts: [],
...overrides,
})
+54 -3
ファイルの表示
@@ -223,12 +223,63 @@ export type Theatre = {
createdAt: string createdAt: string
updatedAt: string } updatedAt: string }
export type TheatreComment = { export type TheatreComment =
theatreId: number, | { theatreId: number
no: number, no: number
deleted: false
user: { id: number, name: string } | null user: { id: number, name: string } | null
content: string content: string
createdAt: string } createdAt: string }
| { theatreId: number
no: number
deleted: true
user: { id: number, name: string } | null
content: null,
createdAt: string }
export type TheatreProgramme = {
theatreId: number
position: number
post: Post
createdAt: string }
export type TheatreSkipVoteStatus = {
votesCount: number
requiredCount: number
watchingUsersCount: number
voted: boolean }
export type TheatreInfo = {
hostFlg: boolean
postId: number | null
postStartedAt: string | null
postElapsedMs: number | null
watchingUsers: Pick<User, 'id' | 'name'>[]
skipVote: TheatreSkipVoteStatus
skipped?: boolean }
export type TheatreSkipEvent = {
id: number
theatreId: number
post: Post
tags: Tag[]
programmePosition: number | null
createdAt: string }
export type TheatrePostWeight = {
post: Post
weight: number
penalty: number
tags: Tag[] }
export type TheatreTagPenalty = {
tag: Tag
penalty: number }
export type TheatrePostSelectionWeights = {
tagPenalties: TheatreTagPenalty[]
lightestPosts: TheatrePostWeight[]
heaviestPosts: TheatrePostWeight[] }
export type User = { export type User = {
id: number id: number