上映会改修 (#302) #357

マージ済み
みてるぞ が 13 個のコミットを feature/302 から main へマージ 2026-06-07 02:51:26 +09:00
22個のファイルの変更1290行の追加245行の削除
コミット 81e620c33a の変更だけを表示してゐます - すべてのコミットを表示
+3 -1
ファイルの表示
@@ -12,6 +12,8 @@ class TheatreProgrammesController < ApplicationController
.order(position: :desc).limit(100) .order(position: :desc).limit(100)
.limit(limit) .limit(limit)
render json: programmes.as_json(include: { post: PostRepr::BASE }) render json: programmes.map { |programme|
programme.as_json.merge(post: PostRepr.base(programme.post))
}
end end
end end
+24
ファイルの表示
@@ -0,0 +1,24 @@
class TheatreSkipEventsController < ApplicationController
def index
limit = params[:limit].to_i
limit = 50 if limit <= 0
events =
TheatreSkipEvent
.where(theatre_id: params[:theatre_id])
.includes(:skipped_by_user, :users, :tags, post: { tags: :tag_name })
.order(created_at: :desc)
.limit(limit)
render json: events.map { |event|
{ id: event.id,
theatre_id: event.theatre_id,
post: PostRepr.base(event.post),
skipped_by_user: UserRepr.base(event.skipped_by_user),
voters: event.users.map { |user| UserRepr.base(user) },
tags: event.tags.map { |tag| TagRepr.inline(tag) },
programme_position: event.programme_position,
created_at: event.created_at }
}
end
end
+88 -9
ファイルの表示
@@ -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
@@ -44,14 +42,95 @@ class TheatresController < ApplicationController
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do ApplicationRecord.transaction do
post = Post.where("url LIKE '%nicovideo.jp%'") theatre.lock!
.order('RAND()') TheatrePostAdvancer.call(theatre:)
.first
theatre.update!(current_post: post, current_post_started_at: Time.current)
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end end
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
skipped = 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!
TheatreSkipVote.find_or_create_by!(theatre:, post: theatre.current_post, 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
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
if theatre.current_post
TheatreSkipVote.where(theatre:, post: theatre.current_post, user: current_user).delete_all
end
render json: theatre_info_json(theatre, skipped: false)
end
def post_selection_weights
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
render json: TheatrePostSelector.new(theatre:).weight_json
end
private
def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil)
host_flg = theatre.host_user_id == current_user&.id if host_flg.nil?
post_id = theatre.current_post_id if post_id.nil?
post_started_at = theatre.current_post_started_at if post_started_at.nil?
json = { host_flg:,
post_id:,
post_started_at:,
post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil,
watching_users: theatre.watching_users.as_json(only: [:id, :name]),
skip_vote: skip_vote_status(theatre) }
json[:skipped] = skipped unless skipped.nil?
json
end
def skip_vote_status(theatre)
watching_user_ids = theatre.watching_users.ids
watching_users_count = watching_user_ids.size
required_count = (watching_users_count / 2) + 1
post = theatre.current_post
votes =
if post
TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids)
else
TheatreSkipVote.none
end
{ votes_count: post ? votes.count : 0,
required_count:,
watching_users_count:,
voted: post && current_user ? votes.exists?(user_id: current_user.id) : false }
end
end end
+2
ファイルの表示
@@ -8,6 +8,8 @@ class Theatre < ApplicationRecord
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 :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
+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
+92
ファイルの表示
@@ -0,0 +1,92 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
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("url LIKE '%nicovideo.jp%'")
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: TagRepr.inline(tag), penalty: }
}.compact.sort_by { |row| [-row[:penalty], row[:tag]['name'].to_s] }
end
def post_weight_json(candidates)
candidates.map { |candidate|
{ post: PostRepr.base(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| TagRepr.inline(tag) } }
}
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
+4
ファイルの表示
@@ -85,10 +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, :destroy] resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index] 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]
+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
生成ファイル
+69 -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_14_221900) 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
@@ -137,6 +137,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end end
create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false
t.bigint "tag_id", null: false
t.integer "begin_ms", null: false
t.integer "end_ms", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms"
t.index ["tag_id"], name: "fk_rails_8be3847903"
t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms"
t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural"
end
create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
@@ -187,8 +200,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.datetime "original_created_before" t.datetime "original_created_before"
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.integer "video_ms"
t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id"
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
@@ -292,6 +308,46 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id" t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end 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
@@ -456,6 +512,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_implications", "posts", column: "parent_post_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
add_foreign_key "post_similarities", "posts", column: "target_post_id" add_foreign_key "post_similarities", "posts", column: "target_post_id"
add_foreign_key "post_tag_sections", "posts"
add_foreign_key "post_tag_sections", "tags"
add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "posts"
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
@@ -476,6 +534,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts" add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres" 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"
+31
ファイルの表示
@@ -0,0 +1,31 @@
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
end
end
+133 -11
ファイルの表示
@@ -14,10 +14,17 @@ 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!(:youtube_post) do let!(:niconico_post) do
Post.create!( Post.create!(
title: 'youtube post', title: 'niconico post',
url: 'https://www.youtube.com/watch?v=spec123' 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 end
@@ -120,7 +127,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 +185,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 +213,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 +229,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,17 +284,20 @@ 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])
.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
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
youtube_post.destroy! niconico_post.destroy!
second_niconico_post.destroy!
theatre.update!( theatre.update!(
host_user: member, host_user: member,
current_post: other_post, current_post: other_post,
@@ -299,9 +313,117 @@ 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"
end
let(:third_user) { create(:user, :member, name: 'third user') }
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 '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(json['post_id']).to eq(second_niconico_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
end
describe 'DELETE /theatres/:id/skip_vote' do
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"
}.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
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
+7 -3
ファイルの表示
@@ -37,11 +37,12 @@ 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 playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id])
@@ -173,13 +174,16 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle
} }
if (data.eventName === 'error') if (data.eventName === 'error')
{
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]) }, [onError, onLoadComplete, onMetadataChange, playerId])
useLayoutEffect (() => { useLayoutEffect (() => {
if (!(fullScreen)) if (!(fullScreen))
+5 -3
ファイルの表示
@@ -13,10 +13,11 @@ 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
onError?: (data: unknown) => void }
const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => {
const dialogue = useDialogue () const dialogue = useDialogue ()
const [framed, setFramed] = useState (false) const [framed, setFramed] = useState (false)
@@ -39,7 +40,8 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) =
width={640} width={640}
height={360} height={360}
onLoadComplete={onLoadComplete} onLoadComplete={onLoadComplete}
onMetadataChange={onMetadataChange}/>) onMetadataChange={onMetadataChange}
onError={onError}/>)
} }
case 'twitter.com': case 'twitter.com':
+7 -3
ファイルの表示
@@ -128,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)
@@ -240,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)
@@ -253,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
+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
} }
+589 -155
ファイルの表示
@@ -1,96 +1,267 @@
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 SectionTitle from '@/components/common/SectionTitle'
import { useDialogue } from '@/components/dialogues/DialogueProvider' import { useDialogue } from '@/components/dialogues/DialogueProvider'
import MainArea from '@/components/layout/MainArea' import { Button } from '@/components/ui/button'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiDelete, 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 { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors' import { useValidationErrors } from '@/lib/useValidationErrors'
import type { FC, ReactNode, User } from 'react' import type { FC, FormEvent, ReactNode } from 'react'
import type { NiconicoMetadata, import type { NiconicoMetadata,
NiconicoViewerHandle, NiconicoViewerHandle,
Post, Post,
Category,
Tag,
Theatre, Theatre,
TheatreComment, TheatreComment,
TheatreProgramme } from '@/types' TheatreInfo,
TheatrePostSelectionWeights,
type TheatreInfo = { TheatreProgramme,
hostFlg: boolean User } from '@/types'
postId: number | null
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: 'タグ下',
commentsBottom: 'コメント下' }
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
vertical: 'タグ縦',
horizontal: 'タグ横' }
const commentBox: ReactNode[] = (comment: TheatreComment) => 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"> <div key={`${ comment.no }-content`} className="w-full">
{comment.deleted {comment.deleted
? ( ? <span className="text-sm font-bold"></span>
<span className="text-sm font-bold">
</span>)
: comment.content} : comment.content}
</div>), </div>),
( (
<div key={`${ comment.no }-user`} className="w-full text-sm text-right"> <div key={`${ comment.no }-user`} className="w-full text-sm text-right">
by {comment.user by {userName (comment.user)}
? (comment.user.name || `名もなきニジラー(#${ comment.user.id }`)
: '運営'}
</div>), </div>),
( (
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right"> <div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
{dateString (comment.createdAt)} {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>
</>) : 'この時の動画:履歴外'}
</div>)] </div>)]
type Props = { user: User } 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 TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const { id } = useParams () const { id } = useParams ()
const dialogue = useDialogue () const dialogue = useDialogue ()
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
const 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 [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
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])
@@ -114,10 +285,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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 () => {
@@ -133,10 +308,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
}) () }) ()
void refreshProgrammes ()
void refreshWeights ()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id]) }, [id, refreshProgrammes, refreshWeights])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
@@ -167,20 +345,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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)
{ {
@@ -199,7 +381,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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)
@@ -208,33 +390,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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
@@ -251,31 +424,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
}) () }) ()
void (async () => {
try
{
const data = await apiGet<TheatreProgramme[]> (
`/theatres/${ id }/programmes`, { params: { limit: '100' } })
if (!(cancelled))
setProgrammes (data)
}
catch (e)
{
console.error (e)
}
}) ()
return () => { return () => {
cancelled = true cancelled = true
} }
}, [id, theatreInfo.postId]) }, [theatreInfo.postId])
useEffect (() => {
void refreshProgrammes ()
}, [refreshProgrammes, theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => { const syncPlayback = (meta: NiconicoMetadata) => {
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 (meta.currentTime - targetTime)
@@ -284,6 +447,44 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
embedRef.current?.seek (targetTime) embedRef.current?.seek (targetTime)
} }
const handlePlaybackError = async () => {
if (!(theatreInfoRef.current.hostFlg) || loadingRef.current)
return
await advancePost ()
}
const handleSkipVote = async () => {
if (!(id) || !(post))
return
setLoading (true)
try
{
const nextInfo =
theatreInfo.skipVote.voted
? await apiDelete<TheatreInfo> (`/theatres/${ id }/skip_vote`)
: await apiPut<TheatreInfo> (`/theatres/${ id }/skip_vote`)
applyTheatreInfo (nextInfo)
if (nextInfo.skipped)
{
setPost (null)
await refreshProgrammes ()
await refreshWeights ()
}
}
catch (error)
{
console.error (error)
}
finally
{
setLoading (false)
}
}
const handleDelete = async (commentNo: number) => { const handleDelete = async (commentNo: number) => {
try try
{ {
@@ -291,7 +492,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setComments (prev => { setComments (prev => {
const rtn = [...prev] const rtn = [...prev]
const idx = rtn.findIndex (x => x.no === commentNo) const idx = rtn.findIndex (x => x.no === commentNo)
rtn[idx] = { ...rtn[idx], deleted: true } if (idx >= 0)
rtn[idx] = { ...rtn[idx], deleted: true, content: null }
return rtn return rtn
}) })
} }
@@ -301,66 +503,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
} }
} }
if (status >= 400) const handleCommentSubmit = async (e: FormEvent) => {
return <ErrorScreen status={status}/>
return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet>
<meta name="robots" content="noindex"/>
{theatre && (
<title>
{'上映会場'
+ (theatre.name ? `${ theatre.name }` : ` #${ theatre.id }`)
+ ` | ${ SITE_TITLE }`}
</title>)}
</Helmet>
<div className="hidden md:block">
{post && <TagDetailSidebar post={post}/>}
</div>
<MainArea>
{post ? (
<>
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={info => {
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...'}
<div>
<SectionTitle>
</SectionTitle>
<div>
{programmes.map ((programme, i) => (
<div key={i}>
<PrefetchLink to={`/posts/${ programme.post.id }`}>
{programme.post.title}
</PrefetchLink>
{dateString (programme.createdAt)}
</div>))}
</div>
</div>
</MainArea>
<SidebarComponent>
<form
className="w-auto h-auto border border-black dark:border-white rounded mx-2"
onSubmit={async e => {
e.preventDefault () e.preventDefault ()
if (!(content)) if (!(content))
@@ -382,7 +525,61 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{ {
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 border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<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 border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<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"
@@ -391,22 +588,22 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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 dark:border-zinc-800">
h-[32vh] md:h-[64vh] border rounded"> {comments.map (comment => {
{comments.map (comment => ( const commentProgramme = programmeForComment (comment)
return (
<div <div
key={comment.no} key={comment.no}
className="p-2 group relative rounded py-1 hover:bg-gray-100 className="group relative border-t border-zinc-100 p-2 first:border-t-0 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
dark:hover:bg-gray-800">
{(user && comment.user?.id === user.id && !(comment.deleted)) && ( {(user && comment.user?.id === user.id && !(comment.deleted)) && (
<button <button
type="button" type="button"
className="absolute left-1 top-1 hidden rounded text-md text-red-600 className="absolute left-1 top-1 hidden rounded px-1 text-red-600 hover:bg-red-100 group-hover:inline-block dark:text-red-300 dark:hover:bg-red-950"
hover:bg-red-100 group-hover:inline-block
dark:text-red-300 dark:hover:bg-red-950"
aria-label="コメントを削除" aria-label="コメントを削除"
onClick={async e => { onClick={async e => {
e.stopPropagation () e.stopPropagation ()
@@ -414,9 +611,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(await dialogue.confirm ({ if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?', title: 'このコメントを削除しますか?',
description: ( description: (
<div className="border border-black dark:border-white rounded <div className="my-3 w-64 rounded border border-black p-2 dark:border-white">
my-3 p-2 w-64"> {commentBox (comment, commentProgramme)}
{commentBox (comment)}
</div>), </div>),
confirmText: '削除', confirmText: '削除',
variant: 'danger' }))) variant: 'danger' })))
@@ -426,33 +622,271 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}}> }}>
&times; &times;
</button>)} </button>)}
{commentBox (comment)} {commentBox (comment, commentProgramme)}
</div>)
})}
</div>
</section>)
const participantsPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<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 border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
{theatreInfo.watchingUsers.length} <h2 className="mb-3 font-bold"></h2>
<div className="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-t border-zinc-100 py-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">
#{programme.position} / {dateString (programme.createdAt)}
</div>
</div>))}
</div>
</section>)
const weightsPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<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="grid gap-4 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>) : weights.tagPenalties.slice (0, 12).map (row => (
</li>))} <div key={row.tag.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2 text-left">
</ul> <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 p-3 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 (layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border border-zinc-300 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-center justify-between gap-3 border-b 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">
{post ? (
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={info => {
embedRef.current?.play ()
setVideoLength (info.lengthInSeconds * 1_000)
}}
onMetadataChange={syncPlayback}
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>
<Button
type="button"
size="sm"
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (post)}>
稿
</Button>
</div>
</section>
{editingPost && (
<section className="rounded border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<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>
<Button type="button" variant="outline" size="sm" onClick={() => setEditingPost (null)}>
</Button>
</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-t 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
+42 -2
ファイルの表示
@@ -226,7 +226,7 @@ export type Theatre = {
export type TheatreComment = export type TheatreComment =
| { theatreId: number | { theatreId: number
no: number no: number
deteled: false deleted: false
user: { id: number, name: string } | null user: { id: number, name: string } | null
content: string content: string
createdAt: string } createdAt: string }
@@ -234,7 +234,7 @@ export type TheatreComment =
no: number no: number
deleted: true deleted: true
user: { id: number, name: string } | null user: { id: number, name: string } | null
content null, content: null,
createdAt: string } createdAt: string }
export type TheatreProgramme = { export type TheatreProgramme = {
@@ -243,6 +243,46 @@ export type TheatreProgramme = {
post: Post post: Post
createdAt: string } 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
skippedByUser: Pick<User, 'id' | 'name'>
voters: Pick<User, 'id' | 'name'>[]
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
name: string | null name: string | null