From 3980e9651e217e0754982368b3bacb82edffdbf7 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:51:25 +0900 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E6=98=A0=E4=BC=9A=E6=94=B9=E4=BF=AE?= =?UTF-8?q?=20(#302)=20(#357)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://git.miteruzo.com/miteruzo/btrc-hub/pulls/357 Co-authored-by: miteruzo Co-committed-by: miteruzo --- AGENTS.md | 89 +- backend/AGENTS.md | 115 ++- backend/app/controllers/posts_controller.rb | 8 +- .../theatre_comments_controller.rb | 26 +- .../theatre_programmes_controller.rb | 22 + .../theatre_skip_events_controller.rb | 22 + .../app/controllers/theatres_controller.rb | 121 ++- backend/app/models/tag.rb | 2 +- backend/app/models/theatre.rb | 4 + backend/app/models/theatre_programme.rb | 6 + backend/app/models/theatre_skip_event.rb | 10 + backend/app/models/theatre_skip_event_tag.rb | 6 + .../app/models/theatre_skip_event_voter.rb | 6 + backend/app/models/theatre_skip_vote.rb | 7 + backend/app/services/theatre_post_advancer.rb | 29 + backend/app/services/theatre_post_selector.rb | 119 +++ .../app/services/theatre_skip_finalizer.rb | 40 + backend/config/routes.rb | 7 +- ...0260514221900_create_theatre_programmes.rb | 10 + ...00_create_theatre_skip_votes_and_events.rb | 36 + backend/db/schema.rb | 63 +- .../spec/requests/theatre_comments_spec.rb | 60 ++ .../spec/requests/theatre_programmes_spec.rb | 38 + backend/spec/requests/theatres_spec.rb | 234 ++++- frontend/AGENTS.md | 84 +- frontend/src/App.tsx | 2 +- frontend/src/components/NicoViewer.tsx | 119 ++- frontend/src/components/PostEmbed.tsx | 96 +- frontend/src/components/TopNav.tsx | 20 +- frontend/src/lib/api.ts | 9 +- frontend/src/lib/users.ts | 1 + .../pages/theatres/TheatreDetailPage.test.tsx | 204 ++++ .../src/pages/theatres/TheatreDetailPage.tsx | 903 +++++++++++++++--- frontend/src/test/factories.ts | 70 +- frontend/src/types.ts | 61 +- 35 files changed, 2344 insertions(+), 305 deletions(-) create mode 100644 backend/app/controllers/theatre_programmes_controller.rb create mode 100644 backend/app/controllers/theatre_skip_events_controller.rb create mode 100644 backend/app/models/theatre_programme.rb create mode 100644 backend/app/models/theatre_skip_event.rb create mode 100644 backend/app/models/theatre_skip_event_tag.rb create mode 100644 backend/app/models/theatre_skip_event_voter.rb create mode 100644 backend/app/models/theatre_skip_vote.rb create mode 100644 backend/app/services/theatre_post_advancer.rb create mode 100644 backend/app/services/theatre_post_selector.rb create mode 100644 backend/app/services/theatre_skip_finalizer.rb create mode 100644 backend/db/migrate/20260514221900_create_theatre_programmes.rb create mode 100644 backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb create mode 100644 backend/spec/requests/theatre_programmes_spec.rb create mode 100644 frontend/src/pages/theatres/TheatreDetailPage.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 7ab89a3..16aa3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,16 +12,21 @@ BTRC Hub / タグ広場 is a split Rails API and React frontend repository. ## Stack - 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 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 - `backend/app/controllers`: Rails API controllers. - `backend/app/models`: Active Record models. - `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/db/migrate`: migrations. - `backend/db/schema.rb`: current schema snapshot. @@ -89,7 +94,8 @@ npm run test:run 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. @@ -98,40 +104,77 @@ npm run preview - Prefer precise, minimal changes. - Do not flatter or over-explain. - 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 line break immediately before `)`. - 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. ## 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`. -- 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. -- Preserve the `X-Transfer-Code` user identification flow unless the task explicitly changes authentication. -- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior. +- Preserve the `X-Transfer-Code` user identification flow unless the task + explicitly changes authentication. +- Be careful with version tables, `version_no`, optimistic concurrency, + wiki revisions, and restore/diff behavior. - Be careful with tag names, tag normalization, implications, similarities, and discard behavior. +- 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. ## Frontend rules - 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`. - 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. ### 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. -- Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward. -- 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. -- 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 `)` rather than moving `)` onto a separate line. +- Put two blank lines before and after top-level `const` function + declarations, unless imports, exports, or file boundaries make that awkward. +- In TSX, indent with 4-space logical indentation. +- A leading tab is exactly equivalent to 8 leading spaces. +- Keep a tag's closing marker on the same line as the final prop when the tag + spans multiple lines. +- Do not put `/>` or `>` on its own line unless the existing surrounding code + does so. +- Keep JSX closing parentheses in the existing compact style, for example + `)` 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: @@ -164,10 +207,14 @@ function PostFormTagsArea ({ tags, setTags }: Props) { - First inspect existing patterns; do not invent new architecture when a local convention exists. - 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. -- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication 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`. +- Do not scan or summarize dependency/generated/runtime directories such as + `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed. +- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication + behavior, inspect the related request specs and service objects. +- 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. ## Completion criteria diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 33d1b1f..9553712 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -4,7 +4,9 @@ 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 @@ -50,32 +52,57 @@ If a command cannot be run or fails, report the exact command and failure. - `app/controllers`: API controllers. - `app/models`: Active Record models and concerns. - `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. - `db/migrate`: migrations. - `db/schema.rb`: schema snapshot. - `lib/tasks`: custom Rake tasks. - `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 - Prefer precise, minimal changes. - Use single quotes unless interpolation or escaping makes double quotes better. - 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. +- 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. - Do not add production dependencies without approval. ## 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`. -- Do not bypass or weaken the `X-Transfer-Code` flow unless the task explicitly changes authentication. -- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers. +- Do not bypass or weaken the `X-Transfer-Code` flow unless the task + explicitly changes authentication. +- Unauthenticated write actions should return `:unauthorized` consistently + with existing controllers. - 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. - 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#banned?` and `IpAddress#banned?` check `banned_at.present?`. - 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 @@ -99,49 +127,86 @@ Before changing behavior, inspect the matching route, controller, model, service - Put Rake task coverage under `spec/tasks`. - `spec/rails_helper.rb` loads `spec/support/**/*.rb`. - Request specs include `AuthHelper` and `JsonHelper`. -- `AuthHelper#sign_in_as(user)` stubs `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. +- `AuthHelper#sign_in_as(user)` stubs + `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 - Keep migrations and `db/schema.rb` consistent. - 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. - 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 - 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 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` - `TagVersionRecorder` - `NicoTagVersionRecorder` - `WikiVersionRecorder` - `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. -- 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 -- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior. -- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots. -- 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. +- Posts have tag snapshots, parent post implications, original-created ranges, + viewed state, and version conflict behavior. +- Tags have canonical names, aliases through `TagName`, categories, parent + implications, discard behavior, and version snapshots. +- 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 - 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. -- 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. +- 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. +- 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 -- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed. -- Do not modify generated schema or migration output without the corresponding migration when schema changes are made. +- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency + directories unless explicitly needed. +- Do not modify generated schema or migration output without the corresponding + migration when schema changes are made. diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index 7bddd07..db39065 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -44,7 +44,8 @@ class PostsController < ApplicationController filtered_posts .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")) - .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 q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -95,7 +96,7 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(:uploaded_user, + post = filtered_posts.preload(:uploaded_user, :parents, :children, tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .with_attached_thumbnail .order('RAND()') @@ -108,7 +109,8 @@ class PostsController < ApplicationController def show 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 .find_by(id: params[:id]) return head :not_found unless post diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb index fdc7ba1..599a57e 100644 --- a/backend/app/controllers/theatre_comments_controller.rb +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -1,14 +1,21 @@ class TheatreCommentsController < ApplicationController def index + limit = params[:limit].to_i + limit = 20 if limit <= 0 + no_gt = params[:no_gt].to_i - no_gt = 0 if no_gt.negative? + no_gt = 0 if no_gt < 0 comments = TheatreComment .where(theatre_id: params[:theatre_id]) .where('no > ?', no_gt) .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 def create @@ -29,4 +36,19 @@ class TheatreCommentsController < ApplicationController render json: comment, status: :created 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 diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb new file mode 100644 index 0000000..b386f63 --- /dev/null +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -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 diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb new file mode 100644 index 0000000..fbbdae9 --- /dev/null +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -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 diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 7949045..10f4455 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -31,9 +31,7 @@ class TheatresController < ApplicationController post_started_at = theatre.current_post_started_at end - render json: { - host_flg:, post_id:, post_started_at:, - watching_users: theatre.watching_users.as_json(only: [:id, :name]) } + render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:) end def next_post @@ -43,12 +41,119 @@ class TheatresController < ApplicationController return head :not_found unless theatre return head :forbidden if theatre.host_user != current_user - post = Post.where("url LIKE '%nicovideo.jp%'") - .or(Post.where("url LIKE '%youtube.com%'")) - .order('RAND()') - .first - theatre.update!(current_post: post, current_post_started_at: Time.current) + ApplicationRecord.transaction do + theatre.lock! + TheatrePostAdvancer.call(theatre:) + end head :no_content 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 diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 51ca783..5048e45 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -81,7 +81,7 @@ class Tag < ApplicationRecord 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.bot = find_or_create_by_tag_name!('bot操作', category: :meta) diff --git a/backend/app/models/theatre.rb b/backend/app/models/theatre.rb index 1da8555..7912fe3 100644 --- a/backend/app/models/theatre.rb +++ b/backend/app/models/theatre.rb @@ -7,6 +7,10 @@ class Theatre < ApplicationRecord class_name: 'TheatreWatchingUser', inverse_of: :theatre 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 :current_post, class_name: 'Post', optional: true belongs_to :created_by_user, class_name: 'User' diff --git a/backend/app/models/theatre_programme.rb b/backend/app/models/theatre_programme.rb new file mode 100644 index 0000000..58e2298 --- /dev/null +++ b/backend/app/models/theatre_programme.rb @@ -0,0 +1,6 @@ +class TheatreProgramme < ApplicationRecord + self.primary_key = :theatre_id, :position + + belongs_to :theatre + belongs_to :post +end diff --git a/backend/app/models/theatre_skip_event.rb b/backend/app/models/theatre_skip_event.rb new file mode 100644 index 0000000..80936ed --- /dev/null +++ b/backend/app/models/theatre_skip_event.rb @@ -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 diff --git a/backend/app/models/theatre_skip_event_tag.rb b/backend/app/models/theatre_skip_event_tag.rb new file mode 100644 index 0000000..e9ce248 --- /dev/null +++ b/backend/app/models/theatre_skip_event_tag.rb @@ -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 diff --git a/backend/app/models/theatre_skip_event_voter.rb b/backend/app/models/theatre_skip_event_voter.rb new file mode 100644 index 0000000..3db5b71 --- /dev/null +++ b/backend/app/models/theatre_skip_event_voter.rb @@ -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 diff --git a/backend/app/models/theatre_skip_vote.rb b/backend/app/models/theatre_skip_vote.rb new file mode 100644 index 0000000..ecba421 --- /dev/null +++ b/backend/app/models/theatre_skip_vote.rb @@ -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 diff --git a/backend/app/services/theatre_post_advancer.rb b/backend/app/services/theatre_post_advancer.rb new file mode 100644 index 0000000..c7d9e6c --- /dev/null +++ b/backend/app/services/theatre_post_advancer.rb @@ -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 diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb new file mode 100644 index 0000000..124c913 --- /dev/null +++ b/backend/app/services/theatre_post_selector.rb @@ -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 diff --git a/backend/app/services/theatre_skip_finalizer.rb b/backend/app/services/theatre_skip_finalizer.rb new file mode 100644 index 0000000..b098df1 --- /dev/null +++ b/backend/app/services/theatre_skip_finalizer.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 6e62f1e..ab9fdc3 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -85,9 +85,14 @@ Rails.application.routes.draw do member do put :watching patch :next_post + put :skip_vote + delete :skip_vote, action: :unskip_vote + get :post_selection_weights 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 resources :materials, only: [:index, :show, :create, :update, :destroy] diff --git a/backend/db/migrate/20260514221900_create_theatre_programmes.rb b/backend/db/migrate/20260514221900_create_theatre_programmes.rb new file mode 100644 index 0000000..f89f04b --- /dev/null +++ b/backend/db/migrate/20260514221900_create_theatre_programmes.rb @@ -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 diff --git a/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb new file mode 100644 index 0000000..8361d6a --- /dev/null +++ b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 94edb82..9fe2736 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_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| t.string "name", 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" 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| t.bigint "theatre_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 "theatre_comments", "theatres" 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", "users" add_foreign_key "theatres", "posts", column: "current_post_id" diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb index 856b309..78295be 100644 --- a/backend/spec/requests/theatre_comments_spec.rb +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do expect(response).to have_http_status(:ok) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) 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 describe 'POST /theatres/:theatre_id/comments' do @@ -147,4 +167,44 @@ RSpec.describe 'TheatreComments', type: :request do }) 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 diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb new file mode 100644 index 0000000..f37fa66 --- /dev/null +++ b/backend/spec/requests/theatre_programmes_spec.rb @@ -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 diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 45a4b85..7fd3a0a 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -14,10 +14,24 @@ RSpec.describe 'Theatres API', type: :request do let(:member) { create(:user, :member, name: 'member 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 Post.create!( title: 'youtube post', - url: 'https://www.youtube.com/watch?v=spec123' + url: 'https://www.youtube.com/watch?v=yt123' ) end @@ -120,7 +134,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => true, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -177,7 +192,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => false, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -204,7 +220,7 @@ RSpec.describe 'Theatres API', type: :request do ) theatre.update!( host_user: other_user, - current_post: youtube_post, + current_post: niconico_post, current_post_started_at: started_at ) 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(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'])) .to be_within(1.second).of(started_at) + expect(json['post_elapsed_ms']) + .to be_within(1_000).of(120_000) 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 expect { do_request } .to change { theatre.reload.current_post_id } - .from(nil).to(youtube_post.id) 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) .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 context 'when current user is host and no eligible post exists' do before do + niconico_post.destroy! + second_niconico_post.destroy! youtube_post.destroy! theatre.update!( host_user: member, @@ -299,9 +337,189 @@ RSpec.describe 'Theatres API', type: :request do theatre.reload expect(theatre.current_post_id).to be_nil - expect(theatre.current_post_started_at) - .to be_within(1.second).of(Time.current) + expect(theatre.current_post_started_at).to be_nil 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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d24aaa2..124c9b7 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -4,7 +4,8 @@ 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 @@ -17,9 +18,11 @@ npm run lint 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: @@ -32,18 +35,37 @@ If either command cannot be run or fails, report the exact command and failure. ## 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. - Use `import type` for type-only imports. - 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. +- 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 - 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. +- 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 shared and feature components under `src/components`. - 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 - 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. -- Fetch functions should live in domain helpers under `src/lib`, such as `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. +- Query keys should come from `src/lib/queryKeys.ts`; add key builders there + instead of using ad hoc arrays in components. +- Fetch functions should live in domain helpers under `src/lib`, such as + `posts.ts`, `tags.ts`, or `wiki.ts`. +- 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 - 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. -- 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. ## 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}`. - 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. -- 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. +## TSX formatting + +- Preserve compact TSX expression shapes such as inline ternary branches and + closing `)` 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 -- 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. - `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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 784fbf8..e0dd2e9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -64,7 +64,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - }/> + }/> }> }/> }/> diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index a1d0390..c407391 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -1,11 +1,11 @@ import { forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState } from 'react' + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState } from 'react' import type { CSSProperties, ForwardedRef } from 'react' @@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from ' type NiconicoPlayerMessage = | { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' } - | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } } - | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata } - | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown } - | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string } + | { eventName: 'loadComplete' + playerId?: string + data: { videoInfo: NiconicoVideoInfo } } + | { 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 = | { eventName: 'play'; sourceConnectorType: 1; playerId: string } @@ -30,6 +40,7 @@ type NiconicoCommand = data: { commentVisibility: boolean } } const EMBED_ORIGIN = 'https://embed.nicovideo.jp' +const LOAD_COMPLETE_TIMEOUT_MS = 8_000 type Props = { id: string @@ -37,14 +48,18 @@ type Props = { height: number style?: CSSProperties onLoadComplete?: (info: NiconicoVideoInfo) => void - onMetadataChange?: (meta: NiconicoMetadata) => void } + onMetadataChange?: (meta: NiconicoMetadata) => void + onError?: (data: unknown) => void } export default forwardRef ((props: Props, ref: ForwardedRef) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const iframeRef = useRef (null) - const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) + const loadCompleteTimerRef = useRef | null> (null) + const playerId = useMemo ( + () => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, + [id]) const [screenWidth, setScreenWidth] = useState () const [screenHeight, setScreenHeight] = useState () @@ -64,21 +79,39 @@ export default forwardRef ((props: Props, ref: ForwardedRef { + 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 win = iframeRef.current?.contentWindow if (!(win)) @@ -132,21 +165,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef { const onMessage = (event: MessageEvent) => { if (!(iframeRef.current) - || (event.source !== iframeRef.current.contentWindow) - || (event.origin !== EMBED_ORIGIN)) - return + || (event.source !== iframeRef.current.contentWindow) + || (event.origin !== EMBED_ORIGIN)) + return const data = event.data if (!(data) || typeof data !== 'object' || !('eventName' in data)) - return + return if (('playerId' in data) && data.playerId && data.playerId !== playerId) - return + return if (data.eventName === 'enterProgrammaticFullScreen') { @@ -162,6 +195,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef removeEventListener ('message', onMessage) - }, [onLoadComplete, onMetadataChange, playerId]) + }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) + + useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer]) useLayoutEffect (() => { if (!(fullScreen)) @@ -192,7 +232,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef { if (ended) - return + return const isLandscape = innerWidth >= innerHeight const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px` @@ -206,9 +246,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef { if (requestAnimationFrame) - requestAnimationFrame (pollingResize) + requestAnimationFrame (pollingResize) else - pollingResize () + pollingResize () } startPollingResize () @@ -231,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef) + width={width} + height={height} + style={margedStyle} + onLoad={startLoadCompleteTimer} + allowFullScreen + allow="autoplay"/>) }) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 8ae3220..ac7f91d 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import YoutubeEmbed from 'react-youtube' import NicoViewer from '@/components/NicoViewer' @@ -8,17 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider' import type { FC, RefObject } from 'react' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' +import type { YouTubePlayer } from 'react-youtube' + +type YouTubeEvent = { + data: T + target: YouTubePlayer } type Props = { ref?: RefObject post: Post 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 = ({ ref, post, onLoadComplete, onMetadataChange }) => { +const PostEmbed: FC = ({ + ref, + post, + onLoadComplete, + onMetadataChange, + onVideoReady, + onPlaybackChange, + onError, +}) => { const dialogue = useDialogue () const [framed, setFramed] = useState (false) + const [youtubePlayer, setYoutubePlayer] = useState (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) => { + void reportYoutubePlayback (event.target) + } + + const handleYoutubeError = (event: YouTubeEvent) => { + 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) @@ -38,8 +118,9 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange }) = id={videoId} width={640} height={360} - onLoadComplete={onLoadComplete} - onMetadataChange={onMetadataChange}/>) + onLoadComplete={handleNiconicoLoadComplete} + onMetadataChange={handleNiconicoMetadataChange} + onError={onError}/>) } case 'twitter.com': @@ -69,7 +150,10 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange }) = mute: 0, loop: 1, width: '640', - height: '360' } }}/>) + height: '360' } }} + onReady={handleYoutubeReady} + onStateChange={handleYoutubeStateChange} + onError={handleYoutubeError}/>) } } diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index c7772bb..4a7e20b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: '追加', to: '/materials/new' }, { name: '全体履歴', to: '/materials/changes', visible: false }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, - { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - { name: <>第 1 会場, to: '/theatres/1' }, - { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, - { name: <>ニジカ放送局第 1 チャンネル, - to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, 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', visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false }, @@ -132,8 +128,12 @@ const TopNav: FC = ({ user }) => { const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) 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 = visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) + const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const prevActiveIdxRef = useRef (activeIdx) @@ -244,9 +244,9 @@ const TopNav: FC = ({ user }) => {