Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09763982b5 |
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
class TheatreProgramme < ApplicationRecord
|
||||||
|
self.primary_key = :theatre_id, :position
|
||||||
|
|
||||||
|
belongs_to :theatre
|
||||||
|
belongs_to :post
|
||||||
|
end
|
||||||
@@ -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
|
|
||||||
Generated
+12
-19
@@ -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
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :post_tag do
|
|
||||||
association :post
|
|
||||||
association :tag
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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/>}/>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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: <>第 1 会場</>, to: '/theatres/1' },
|
|
||||||
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
|
||||||
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
|
||||||
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
|
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
|
|
||||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
{ name: '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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}}>
|
||||||
|
×
|
||||||
|
</button>)}
|
||||||
|
{commentBox (comment)}
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -345,4 +443,5 @@ const TheatreDetailPage: FC = () => {
|
|||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default TheatreDetailPage
|
export default TheatreDetailPage
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user