Compare commits

..

1 Commits

Author SHA1 Message Date
みてるぞ 09763982b5 #302 2026-05-17 21:09:43 +09:00
29 changed files with 272 additions and 348 deletions
+24 -51
View File
@@ -44,7 +44,8 @@ class PostsController < ApplicationController
filtered_posts filtered_posts
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .preload(:parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.with_attached_thumbnail .with_attached_thumbnail
q = q.where('posts.url LIKE ?', "%#{ url }%") if url q = q.where('posts.url LIKE ?', "%#{ url }%") if url
@@ -95,8 +96,8 @@ class PostsController < ApplicationController
end end
def random def random
post = filtered_posts post = filtered_posts.preload(:parents, :childern,
.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.order('RAND()') .order('RAND()')
.first .first
return head :not_found unless post return head :not_found unless post
@@ -105,11 +106,15 @@ class PostsController < ApplicationController
end end
def show def show
post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) post = Post
.preload(:parents, :children)
.includes(:parents, :children,
tags: [:deerjikists, :materials, { tag_name: :wiki_page }])
.find_by(id: params[:id])
return head :not_found unless post return head :not_found unless post
render json: PostRepr.base(post, current_user) render json: PostRepr.base(post, current_user)
.merge(tags: build_tag_tree_for(post), .merge(tags: build_tag_tree_for(post.tags),
related: PostRepr.many(post.related(limit: 20))) related: PostRepr.many(post.related(limit: 20)))
end end
@@ -133,11 +138,11 @@ class PostsController < ApplicationController
ApplicationRecord.transaction do ApplicationRecord.transaction do
post.save! post.save!
Tag.normalise_tags!(tag_names, with_sections: true) => { tags:, sections: } tags = Tag.normalise_tags!(tag_names)
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags, sections) sync_post_tags!(post, tags)
sync_parent_posts!(post, parent_post_ids) sync_parent_posts!(post, parent_post_ids)
@@ -236,7 +241,7 @@ class PostsController < ApplicationController
post.reload post.reload
json = PostRepr.base(post, current_user) json = PostRepr.base(post, current_user)
json['tags'] = build_tag_tree_for(post) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
@@ -338,7 +343,7 @@ class PostsController < ApplicationController
def tagged_post_ids_for(name) = def tagged_post_ids_for(name) =
Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id) Post.joins(tags: :tag_name).where(tag_names: { name: }).select(:id)
def sync_post_tags! post, desired_tags, sections def sync_post_tags! post, desired_tags
desired_tags.each do |t| desired_tags.each do |t|
t.save! if t.new_record? t.save! if t.new_record?
end end
@@ -357,20 +362,13 @@ class PostsController < ApplicationController
end end
end end
PostTagSection.where(post_id: post.id).destroy_all
sections.each do |tag_id, ranges|
ranges.each do |begin_ms, end_ms|
PostTagSection.create!(post_id: post.id, tag_id:, begin_ms:, end_ms:)
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt| PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user) pt.discard_by!(current_user)
end end
end end
def build_tag_tree_for post def build_tag_tree_for tags
tags = post.tags.to_a tags = tags.to_a
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids) implications = TagImplication.where(parent_tag_id: tag_ids, tag_id: tag_ids)
@@ -392,11 +390,8 @@ class PostsController < ApplicationController
tag = tags_by_id[tag_id] tag = tags_by_id[tag_id]
return nil unless tag return nil unless tag
sections = PostTagSection.where(post_id: post.id, tag_id:)
.as_json(only: [:begin_ms, :end_ms])
if path.include?(tag_id) if path.include?(tag_id)
return TagRepr.base(tag).merge(children: [], sections:) return TagRepr.base(tag).merge(children: [])
end end
if memo.key?(tag_id) if memo.key?(tag_id)
@@ -408,7 +403,7 @@ class PostsController < ApplicationController
children = child_ids.filter_map { |cid| build_node.(cid, new_path) } children = child_ids.filter_map { |cid| build_node.(cid, new_path) }
memo[tag_id] = TagRepr.base(tag).merge(children:, sections:) memo[tag_id] = TagRepr.base(tag).merge(children:)
end end
root_ids.filter_map { |id| build_node.call(id, []) } root_ids.filter_map { |id| build_node.call(id, []) }
@@ -419,7 +414,7 @@ class PostsController < ApplicationController
params[:parent_post_ids].to_s.split.map { |token| params[:parent_post_ids].to_s.split.map { |token|
id = Integer(token, exception: false) id = Integer(token, exception: false)
raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if !(id) || id <= 0 raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0
id id
}.uniq }.uniq
@@ -481,21 +476,7 @@ class PostsController < ApplicationController
end end
def editable_tag_names_from_post post def editable_tag_names_from_post post
post post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
.post_tags
.kept
.joins(tag: :tag_name)
.merge(Tag.not_nico)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end end
def post_incoming_snapshot title:, original_created_from:, original_created_before:, def post_incoming_snapshot title:, original_created_from:, original_created_before:,
@@ -527,16 +508,9 @@ class PostsController < ApplicationController
end end
def incoming_tag_names_for_snapshot raw_tag_names def incoming_tag_names_for_snapshot raw_tag_names
Tag.normalise_tags!(raw_tag_names, with_tagme: false, with_sections: true) => tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false)
{ tags:, sections: }
Tag.expand_parent_tags(tags).uniq(&:id).map { |tag| Tag.expand_parent_tags(tags).map(&:name).uniq.sort
"#{ tag.name }#{ sections[tag.id].to_a.map { section_literal(_1) }.join }"
}.sort
end
def section_literal section
"[#{ Post.ms_to_time(section[0]) }-#{ Post.ms_to_time(section[1]) }]"
end end
def post_conflict_json post:, base_version_no:, base_snapshot:, def post_conflict_json post:, base_version_no:, base_snapshot:,
@@ -623,8 +597,7 @@ class PostsController < ApplicationController
original_created_from: snapshot[:original_created_from], original_created_from: snapshot[:original_created_from],
original_created_before: snapshot[:original_created_before]) original_created_before: snapshot[:original_created_before])
Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false, with_sections: true) => editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false)
{ tags: editable_tags, sections: }
TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user)
readonly_tags = post.tags.nico.to_a readonly_tags = post.tags.nico.to_a
@@ -632,7 +605,7 @@ class PostsController < ApplicationController
tags = readonly_tags + editable_tags tags = readonly_tags + editable_tags
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags, sections) sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids]) sync_parent_posts!(post, snapshot[:parent_post_ids])
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
@@ -1,14 +1,21 @@
class TheatreCommentsController < ApplicationController class TheatreCommentsController < ApplicationController
def index def index
limit = params[:limit].to_i
limit = 20 if limit <= 0
no_gt = params[:no_gt].to_i no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt.negative? no_gt = 0 if no_gt < 0
comments = TheatreComment comments = TheatreComment
.where(theatre_id: params[:theatre_id]) .where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt) .where('no > ?', no_gt)
.order(no: :desc) .order(no: :desc)
.limit(limit)
render json: comments.as_json(include: { user: { only: [:id, :name] } }) render json: comments.map {
_1.as_json(include: { user: { only: [:id, :name] } })
.merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?)
}
end end
def create def create
@@ -29,4 +36,18 @@ class TheatreCommentsController < ApplicationController
render json: comment, status: :created render json: comment, status: :created
end end
def destroy
return head :unauthorized unless current_user
theatre_id = params[:theatre_id].to_i
no = params[:id].to_i
comment = TheatreComment.find_by(theatre_id:, no:)
return head :not_found unless comment
comment.discard!
head :no_content
end
end end
@@ -0,0 +1,17 @@
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)
.order(position: :desc).limit(100)
.limit(limit)
render json: programmes.as_json(include: { post: PostRepr::BASE })
end
end
@@ -43,11 +43,14 @@ class TheatresController < ApplicationController
return head :not_found unless theatre return head :not_found unless theatre
return head :forbidden if theatre.host_user != current_user return head :forbidden if theatre.host_user != current_user
ApplicationRecord.transaction do
post = Post.where("url LIKE '%nicovideo.jp%'") post = Post.where("url LIKE '%nicovideo.jp%'")
.or(Post.where("url LIKE '%youtube.com%'"))
.order('RAND()') .order('RAND()')
.first .first
theatre.update!(current_post: post, current_post_started_at: Time.current) theatre.update!(current_post: post, current_post_started_at: Time.current)
position = (theatre.programmes.maximum(:position) || 0) + 1
theatre.programmes.create!(position:, post:)
end
head :no_content head :no_content
end end
+1 -32
View File
@@ -56,38 +56,7 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
post_tags
.kept
.joins(tag: :tag_name)
.includes(:sections, tag: :tag_name)
.order('tag_names.name')
.map do |post_tag|
name = post_tag.tag.tag_name.name
sections = post_tag.sections.sort_by(&:begin_ms)
next name if sections.empty?
"#{ name }#{ sections.map { Post.section_literal(_1) }.join }"
end
end
def self.section_literal section
"[#{ Post.ms_to_time(section.begin_ms) }-#{ Post.ms_to_time(section.end_ms) }]"
end
def self.ms_to_time ms
total_s = ms / 1_000
s = total_s % 60
min = (total_s / 60) % 60
h = total_s / 3_600
if h.positive?
'%d:%02d:%02d' % [h, min, s]
else
'%d:%02d' % [min, s]
end
end
def snapshot_parent_post_ids = parents.order(:id).pluck(:id) def snapshot_parent_post_ids = parents.order(:id).pluck(:id)
-6
View File
@@ -10,12 +10,6 @@ class PostTag < ApplicationRecord
belongs_to :created_user, class_name: 'User', optional: true belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true belongs_to :deleted_user, class_name: 'User', optional: true
has_many :sections, -> { order(:begin_ms) }, class_name: 'PostTagSection',
foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
dependent: :delete_all,
inverse_of: :post_tag
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: { validates :post_id, uniqueness: {
-20
View File
@@ -1,20 +0,0 @@
class PostTagSection < ApplicationRecord
self.primary_key = :post_id, :tag_id, :begin_ms
belongs_to :post
belongs_to :tag
belongs_to :post_tag, -> { kept }, foreign_key: [:post_id, :tag_id],
primary_key: [:post_id, :tag_id],
inverse_of: :sections,
optional: true
validates :post_id, presence: true
validates :tag_id, presence: true
validates :begin_ms, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :end_ms, presence: true,
numericality: { only_integer: true, greater_than: :begin_ms }
end
+4 -51
View File
@@ -81,7 +81,7 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id def material_id = materials.first&.id
def has_deerjikists = deerjikists.present? def has_deerjikists = deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta) def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta) def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
@@ -92,50 +92,22 @@ class Tag < ApplicationRecord
def self.normalise_tags! tag_names, with_tagme: true, def self.normalise_tags! tag_names, with_tagme: true,
with_no_deerjikist: true, with_no_deerjikist: true,
deny_nico: true, deny_nico: true
with_sections: false
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
raise NicoTagNormalisationError raise NicoTagNormalisationError
end end
sections = { }
tags = tag_names.map do |name| tags = tag_names.map do |name|
pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil] pf, cat = CATEGORY_PREFIXES.find { |p, _| name.downcase.start_with?(p) } || ['', nil]
name = TagName.canonicalise(name.sub(/\A#{ pf }/i, '')).first
name = name.sub(/\A#{ pf }/i, '')
sections_by_tag = []
while n = name.sub!(/^(\S*?)\[([0-9:.]*?)-([0-9:.]*?)\](\S*?)$/, '\1\4 \2 \3')
name, *section_raw = n.split
begin_ms, end_ms = section_raw.map { time_to_ms(_1) }
next if begin_ms == end_ms
begin_ms, end_ms = end_ms, begin_ms if begin_ms > end_ms
sections_by_tag << [begin_ms, end_ms]
end
name = TagName.canonicalise(name).first
find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag| find_or_create_by_tag_name!(name, category: (cat || :general)).tap do |tag|
tag.update!(category: cat) if cat && tag.category != cat tag.update!(category: cat) if cat && tag.category != cat
next if sections_by_tag.empty?
sections[tag.id] ||= []
sections[tag.id].concat(sections_by_tag)
end end
end end
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme) tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) } tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
tags.uniq!(&:id) tags.uniq(&:id)
if with_sections
{ tags:, sections: }
else
tags
end
end end
def self.expand_parent_tags tags def self.expand_parent_tags tags
@@ -256,23 +228,4 @@ class Tag < ApplicationRecord
errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.' errors.add :category, 'ニジラーと紐づいてゐるタグはニジラー・カテゴリである必要があります.'
end end
end end
def self.time_to_ms str
parts = str.split(':')
s_part = parts.pop
s, ms = s_part.split('.')
total_s = s.to_i
if parts.length >= 1
total_s += parts.pop.to_i * 60
end
if parts.length >= 1
total_s += parts.pop.to_i * 3_600
end
total_s * 1_000 + ms.to_s.ljust(3, '0')[0, 3].to_i
end
end end
+2
View File
@@ -7,6 +7,8 @@ class Theatre < ApplicationRecord
class_name: 'TheatreWatchingUser', inverse_of: :theatre class_name: 'TheatreWatchingUser', inverse_of: :theatre
has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :watching_users, through: :active_theatre_watching_users, source: :user
has_many :programmes, class_name: 'TheatreProgramme'
belongs_to :host_user, class_name: 'User', optional: true belongs_to :host_user, class_name: 'User', optional: true
belongs_to :current_post, class_name: 'Post', optional: true belongs_to :current_post, class_name: 'Post', optional: true
belongs_to :created_by_user, class_name: 'User' belongs_to :created_by_user, class_name: 'User'
+6
View File
@@ -0,0 +1,6 @@
class TheatreProgramme < ApplicationRecord
self.primary_key = :theatre_id, :position
belongs_to :theatre
belongs_to :post
end
+2 -1
View File
@@ -87,7 +87,8 @@ Rails.application.routes.draw do
patch :next_post patch :next_post
end end
resources :comments, controller: :theatre_comments, only: [:index, :create] resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy]
resources :programmes, controller: :theatre_programmes, only: [:index]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy] resources :materials, only: [:index, :show, :create, :update, :destroy]
@@ -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
@@ -1,19 +0,0 @@
class CreatePostTagSections < ActiveRecord::Migration[8.0]
def change
create_table :post_tag_sections, primary_key: [:post_id, :tag_id, :begin_ms] do |t|
t.references :post, null: false, foreign_key: true, index: false
t.references :tag, null: false, foreign_key: true, index: false
t.integer :begin_ms, null: false
t.integer :end_ms, null: false
t.timestamps
t.index [:post_id, :begin_ms], name: 'idx_post_tag_sections_post_id_begin_ms'
t.check_constraint 'begin_ms >= 0',
name: 'chk_post_tag_sections_begin_ms_natural'
t.check_constraint 'begin_ms < end_ms',
name: 'chk_post_tag_sections_end_ms_after_begin_ms'
end
end
end
@@ -1,9 +0,0 @@
class AddVideoMsToPosts < ActiveRecord::Migration[8.0]
def change
add_column :posts, :video_ms, :integer
add_index :posts, [:video_ms, :id], name: 'idx_posts_video_ms_id'
add_check_constraint :posts, 'video_ms IS NULL OR video_ms > 0',
name: 'chk_posts_video_ms_positive'
end
end
+12 -19
View File
@@ -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_19_013100) do ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) 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,19 +137,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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
@@ -200,11 +187,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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
@@ -299,6 +283,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
t.index ["user_id"], name: "index_theatre_comments_on_user_id" t.index ["user_id"], name: "index_theatre_comments_on_user_id"
end end
create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "theatre_id", null: false
t.integer "position", null: false
t.bigint "post_id", null: false
t.datetime "created_at", null: false
t.index ["post_id"], name: "index_theatre_programmes_on_post_id"
t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id"
end
create_table "theatre_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
@@ -463,8 +456,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) 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"
@@ -483,6 +474,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_19_013100) do
add_foreign_key "tags", "tag_names" add_foreign_key "tags", "tag_names"
add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "theatres"
add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_comments", "users"
add_foreign_key "theatre_programmes", "posts"
add_foreign_key "theatre_programmes", "theatres"
add_foreign_key "theatre_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"
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post_tag_section do
association :post
association :tag
begin_ms { 1_000 }
end_ms { 2_000 }
end
end
-6
View File
@@ -1,6 +0,0 @@
FactoryBot.define do
factory :post_tag do
association :post
association :tag
end
end
-8
View File
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :post do
sequence(:url) { |n| "https://example.com/factory-post-#{ n }" }
title { 'factory post' }
thumbnail_base { nil }
uploaded_user { nil }
end
end
-29
View File
@@ -1,29 +0,0 @@
RSpec.describe PostTag, type: :model do
describe '#sections' do
it 'loads sections by post_id and tag_id' do
post_tag = create(:post_tag)
section = create(:post_tag_section,
post: post_tag.post,
tag: post_tag.tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to contain_exactly(section)
end
it 'does not load sections for another tag on the same post' do
post = create(:post)
tag = create(:tag)
other_tag = create(:tag)
post_tag = create(:post_tag, post:, tag:)
create(:post_tag_section,
post:,
tag: other_tag,
begin_ms: 1000,
end_ms: 2000)
expect(post_tag.sections).to be_empty
end
end
end
+1 -1
View File
@@ -63,7 +63,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/> <Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/tags/changes" element={<TagHistoryPage/>}/> <Route path="/tags/changes" element={<TagHistoryPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/materials" element={<MaterialBasePage/>}>
<Route index element={<MaterialListPage/>}/> <Route index element={<MaterialListPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="new" element={<MaterialNewPage/>}/>
+7 -11
View File
@@ -8,28 +8,24 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast' import { toast } from '@/components/ui/use-toast'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
import { updatePost } from '@/lib/posts' import { updatePost } from '@/lib/posts'
import { msToTime } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, FormEvent } from 'react'
import type { Post, TagWithSections } from '@/types' import type { Post, Tag } from '@/types'
const tagsToStr = (tags: TagWithSections[]): string => { const tagsToStr = (tags: Tag[]): string => {
const result: Omit<TagWithSections, 'children'>[] = [] const result: Tag[] = []
const walk = (tag: TagWithSections) => { const walk = (tag: Tag) => {
const { children, ...rest } = tag const { children, ...rest } = tag
result.push (rest) result.push (rest)
children.forEach (walk) children?.forEach (walk)
} }
tags.filter (t => t.category !== 'nico').forEach (walk) tags.filter (t => t.category !== 'nico').forEach (walk)
return [...(new Set (result.map (t => return [...(new Set (result.map (t => t.name)))].join (' ')
`${ t.name }${ t.sections
.map (s => `[${ msToTime (s.beginMs) }-${ msToTime (s.endMs) }]`)
.join ('') }`)))].join (' ')
} }
@@ -121,7 +117,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
} }
useEffect (() => { useEffect (() => {
setTags (tagsToStr (post.tags)) setTags(tagsToStr (post.tags))
}, [post]) }, [post])
return ( return (
+7 -7
View File
@@ -26,13 +26,13 @@ import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import type { FC, MutableRefObject, ReactNode } from 'react' import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types' import type { Category, Post, Tag } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] } type TagByCategory = { [key in Category]: Tag[] }
const renderTagTree = ( const renderTagTree = (
tag: TagWithSections, tag: Tag,
nestLevel: number, nestLevel: number,
path: string, path: string,
suppressClickRef: MutableRefObject<boolean>, suppressClickRef: MutableRefObject<boolean>,
@@ -63,7 +63,7 @@ const renderTagTree = (
const isDescendant = ( const isDescendant = (
root: TagWithSections, root: Tag,
targetId: number, targetId: number,
): boolean => { ): boolean => {
if (!(root.children)) if (!(root.children))
@@ -84,8 +84,8 @@ const isDescendant = (
const findTag = ( const findTag = (
byCat: TagByCategory, byCat: TagByCategory,
id: number, id: number,
): TagWithSections | undefined => { ): Tag | undefined => {
const walk = (nodes: TagWithSections[]): TagWithSections | undefined => { const walk = (nodes: Tag[]): Tag | undefined => {
for (const t of nodes) for (const t of nodes)
{ {
if (t.id === id) if (t.id === id)
@@ -167,7 +167,7 @@ const TagDetailSidebar: FC<Props> = ({ post, sp }) => {
} }
for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[]) for (const cat of Object.keys (tagsTmp) as (keyof typeof tagsTmp)[])
tagsTmp[cat].sort ((tagA: TagWithSections, tagB: TagWithSections) => tagA.name < tagB.name ? -1 : 1) tagsTmp[cat].sort ((tagA: Tag, tagB: Tag) => tagA.name < tagB.name ? -1 : 1)
return tagsTmp return tagsTmp
}, [post]) }, [post])
+3 -7
View File
@@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '追加', to: '/materials/new' }, { name: '追加', to: '/materials/new' },
{ name: '全体履歴', to: '/materials/changes', visible: false }, { name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', to: '/wiki' }, { name: '検索', to: '/wiki' },
{ name: '新規', to: '/wiki/new' }, { name: '新規', to: '/wiki/new' },
@@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
visible: wikiPageFlg }, visible: wikiPageFlg },
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
{ name: 'おたのしみ', visible: false, subMenu: [
{ name: '上映会 (β)', to: '/theatres/1' }] },
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
{ name: '一覧', to: '/users', visible: false }, { name: '一覧', to: '/users', visible: false },
{ name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false },
@@ -267,7 +263,7 @@ const TopNav: FC<Props> = ({ user }) => {
: { initial: { x: 40, y: -40, opacity: 0 }, : { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 }, animate: { x: 0, y: 0, opacity: 1 },
exit: { x: 40, y: -40, opacity: 0 } })} exit: { x: 40, y: -40, opacity: 0 } })}
className="z-10 h-full flex items-center px-3 font-bold w-24"> className="z-10 h-full flex items-center px-3 font-bold w-28">
<h2>{item.name}</h2> <h2>{item.name}</h2>
</motion.div> </motion.div>
{item.subMenu {item.subMenu
-12
View File
@@ -71,15 +71,3 @@ export const originalCreatedAtString = (
.join (' ')) .join (' '))
return rtn === '〜' ? '年月日不詳' : rtn return rtn === '〜' ? '年月日不詳' : rtn
} }
export const msToTime = (ms: number): string => {
const totalS = Math.trunc (ms / 1_000)
const s = String (totalS % 60)
const min = String (Math.trunc (totalS / 60) % 60)
const h = Math.trunc (totalS / 3_600)
return (h > 0
? `${ h }:${ min.padStart (2, '0') }:${ s.padStart (2, '0') }`
: `${ min }:${ s.padStart (2, '0') }`)
}
@@ -1,6 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
@@ -70,11 +69,6 @@ const DeerjikistDetailPage: FC = () => {
return ( return (
<MainArea> <MainArea>
<Helmet>
<meta name="robots" content="noindex"/>
{tag && <title>{tag.name} | </title>}
</Helmet>
{(loading || !(tag)) ? 'Loading...' : ( {(loading || !(tag)) ? 'Loading...' : (
<div className="max-w-xl"> <div className="max-w-xl">
<PageTitle> <PageTitle>
+117 -18
View File
@@ -6,20 +6,24 @@ import ErrorScreen from '@/components/ErrorScreen'
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 TagDetailSidebar from '@/components/TagDetailSidebar'
import SectionTitle from '@/components/common/SectionTitle'
import { useDialogue } from '@/components/dialogues/DialogueProvider'
import { Button } from '@/components/ui/button'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { apiGet, apiDelete, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import { dateString } from '@/lib/utils' import { dateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC, User } from 'react'
import type { NiconicoMetadata, import type { NiconicoMetadata,
NiconicoViewerHandle, NiconicoViewerHandle,
Post, Post,
Theatre, Theatre,
TheatreComment } from '@/types' TheatreComment,
TheatreProgramme } from '@/types'
type TheatreInfo = { type TheatreInfo = {
hostFlg: boolean hostFlg: boolean
@@ -34,9 +38,36 @@ const INITIAL_THEATRE_INFO =
watchingUsers: [] as { id: number; name: string }[] } as const watchingUsers: [] as { id: number; name: string }[] } as const
const TheatreDetailPage: FC = () => { const commentBox: ReactNode[] = (comment: TheatreComment) =>
[(
<div key={`${ comment.no }-content`} className="w-full">
{comment.deleted
? (
<span className="text-sm font-bold">
</span>)
: comment.content}
</div>),
(
<div key={`${ comment.no }-user`} className="w-full text-sm text-right">
by {comment.user
? (comment.user.name || `名もなきニジラー(#${ comment.user.id }`)
: '運営'}
</div>),
(
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
{dateString (comment.createdAt)}
</div>)]
type Props = { user: User }
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const { id } = useParams () const { id } = useParams ()
const dialogue = useDialogue ()
const commentsRef = useRef<HTMLDivElement> (null) const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const loadingRef = useRef (false) const loadingRef = useRef (false)
@@ -52,6 +83,7 @@ const TheatreDetailPage: FC = () => {
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)
useEffect (() => { useEffect (() => {
@@ -118,7 +150,7 @@ const TheatreDetailPage: FC = () => {
{ {
const newComments = await apiGet<TheatreComment[]> ( const newComments = await apiGet<TheatreComment[]> (
`/theatres/${ id }/comments`, `/theatres/${ id }/comments`,
{ params: { no_gt: lastCommentNoRef.current } }) { params: { no_gt: lastCommentNoRef.current, limit: '20' } })
if (!(cancelled) && newComments.length > 0) if (!(cancelled) && newComments.length > 0)
{ {
@@ -214,10 +246,24 @@ const TheatreDetailPage: FC = () => {
} }
}) () }) ()
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
} }
}, [theatreInfo.postId]) }, [id, theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => { const syncPlayback = (meta: NiconicoMetadata) => {
if (!(theatreInfo.postStartedAt)) if (!(theatreInfo.postStartedAt))
@@ -233,12 +279,30 @@ const TheatreDetailPage: FC = () => {
embedRef.current?.seek (targetTime) embedRef.current?.seek (targetTime)
} }
const handleDelete = async (commentNo: number) => {
try
{
await apiDelete (`/theatres/${ id }/comments/${ commentNo }`)
setComments (prev => {
const rtn = [...prev]
const idx = rtn.findIndex (x => x.no === commentNo)
rtn[idx] = { ...rtn[idx], deleted: true }
return rtn
})
}
catch
{
;
}
}
if (status >= 400) if (status >= 400)
return <ErrorScreen status={status}/> return <ErrorScreen status={status}/>
return ( return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<Helmet> <Helmet>
<meta name="robots" content="noindex"/>
{theatre && ( {theatre && (
<title> <title>
{'上映会場' {'上映会場'
@@ -270,6 +334,22 @@ const TheatreDetailPage: FC = () => {
</PrefetchLink> </PrefetchLink>
</div> </div>
</>) : 'Loading...'} </>) : '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> </MainArea>
<SidebarComponent> <SidebarComponent>
@@ -306,18 +386,36 @@ const TheatreDetailPage: FC = () => {
className="overflow-x-hidden overflow-y-scroll text-wrap w-full className="overflow-x-hidden overflow-y-scroll text-wrap w-full
h-[32vh] md:h-[64vh] border rounded"> h-[32vh] md:h-[64vh] border rounded">
{comments.map (comment => ( {comments.map (comment => (
<div key={comment.no} className="p-2"> <div
<div className="w-full"> key={comment.no}
{comment.content} className="p-2 group relative rounded py-1 hover:bg-gray-100
</div> dark:hover:bg-gray-800">
<div className="w-full text-sm text-right"> {(user && comment.user?.id === user.id && !(comment.deleted)) && (
by {comment.user <button
? (comment.user.name || `名もなきニジラー(#${ comment.user.id }`) type="button"
: '運営'} className="absolute left-1 top-1 hidden rounded text-md text-red-600
</div> hover:bg-red-100 group-hover:inline-block
<div className="w-full text-sm text-right"> dark:text-red-300 dark:hover:bg-red-950"
{dateString (comment.createdAt)} aria-label="コメントを削除"
</div> onClick={async e => {
e.stopPropagation ()
if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?',
description: (
<div className="border border-black dark:border-white rounded
my-3 p-2 w-64">
{commentBox (comment)}
</div>),
confirmText: '削除',
variant: 'danger' })))
return
await handleDelete (comment.no)
}}>
&times;
</button>)}
{commentBox (comment)}
</div>))} </div>))}
</div> </div>
</form> </form>
@@ -345,4 +443,5 @@ const TheatreDetailPage: FC = () => {
</div>) </div>)
} }
export default TheatreDetailPage export default TheatreDetailPage
+2 -4
View File
@@ -1,6 +1,6 @@
import type { Material, Post, TagWithSections, User, WikiPage } from '@/types' import type { Material, Post, Tag, User, WikiPage } from '@/types'
export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSections => ({ export const buildTag = (overrides: Partial<Tag> = {}): Tag => ({
id: 1, id: 1,
name: 'テストタグ', name: 'テストタグ',
category: 'general', category: 'general',
@@ -13,8 +13,6 @@ export const buildTag = (overrides: Partial<TagWithSections> = {}): TagWithSecti
materialId: null, materialId: null,
hasDeerjikists: false, hasDeerjikists: false,
matchedAlias: null, matchedAlias: null,
sections: [],
children: [],
...overrides, ...overrides,
}) })
+18 -8
View File
@@ -126,7 +126,7 @@ export type Post = {
title: string | null title: string | null
thumbnail: string | null thumbnail: string | null
thumbnailBase: string | null thumbnailBase: string | null
tags: TagWithSections[] tags: Tag[]
parentPosts?: Post[] parentPosts?: Post[]
childPosts?: Post[] childPosts?: Post[]
siblingPosts?: Record<`${ number }`, Post[]> siblingPosts?: Record<`${ number }`, Post[]>
@@ -187,6 +187,7 @@ export type Tag = {
hasWiki: boolean hasWiki: boolean
materialId: number | null materialId: number | null
hasDeerjikists: boolean hasDeerjikists: boolean
children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }
export type TagVersion = { export type TagVersion = {
@@ -200,10 +201,6 @@ export type TagVersion = {
createdAt: string createdAt: string
createdByUser: { id: number; name: string | null } | null } createdByUser: { id: number; name: string | null } | null }
export type TagWithSections = Tag & { sections: { beginMs: number
endMs: number }[]
children: TagWithSections[] }
export type Theatre = { export type Theatre = {
id: number id: number
name: string | null name: string | null
@@ -213,12 +210,25 @@ export type Theatre = {
createdAt: string createdAt: string
updatedAt: string } updatedAt: string }
export type TheatreComment = { export type TheatreComment =
theatreId: number, | { theatreId: number
no: number, no: number
deteled: false
user: { id: number, name: string } | null user: { id: number, name: string } | null
content: string content: string
createdAt: string } createdAt: string }
| { theatreId: number
no: number
deleted: true
user: { id: number, name: string } | null
content null,
createdAt: string }
export type TheatreProgramme = {
theatreId: number
position: number
post: Post
createdAt: string }
export type User = { export type User = {
id: number id: number