Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0065af00a |
+2
-2
@@ -50,6 +50,8 @@ group :development, :test do
|
|||||||
gem 'factory_bot_rails'
|
gem 'factory_bot_rails'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
gem "mysql2", "~> 0.5.6"
|
gem "mysql2", "~> 0.5.6"
|
||||||
|
|
||||||
gem "image_processing", "~> 1.14"
|
gem "image_processing", "~> 1.14"
|
||||||
@@ -67,5 +69,3 @@ gem 'whenever', require: false
|
|||||||
gem 'discard'
|
gem 'discard'
|
||||||
|
|
||||||
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
||||||
|
|
||||||
gem 'aws-sdk-s3', require: false
|
|
||||||
|
|||||||
@@ -73,25 +73,6 @@ GEM
|
|||||||
tzinfo (~> 2.0, >= 2.0.5)
|
tzinfo (~> 2.0, >= 2.0.5)
|
||||||
uri (>= 0.13.1)
|
uri (>= 0.13.1)
|
||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
aws-eventstream (1.4.0)
|
|
||||||
aws-partitions (1.1231.0)
|
|
||||||
aws-sdk-core (3.244.0)
|
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
|
||||||
aws-sigv4 (~> 1.9)
|
|
||||||
base64
|
|
||||||
bigdecimal
|
|
||||||
jmespath (~> 1, >= 1.6.1)
|
|
||||||
logger
|
|
||||||
aws-sdk-kms (1.123.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.244.0)
|
|
||||||
aws-sigv4 (~> 1.5)
|
|
||||||
aws-sdk-s3 (1.217.0)
|
|
||||||
aws-sdk-core (~> 3, >= 3.244.0)
|
|
||||||
aws-sdk-kms (~> 1)
|
|
||||||
aws-sigv4 (~> 1.5)
|
|
||||||
aws-sigv4 (1.12.1)
|
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
|
||||||
base64 (0.2.0)
|
base64 (0.2.0)
|
||||||
bcrypt_pbkdf (1.1.1)
|
bcrypt_pbkdf (1.1.1)
|
||||||
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
||||||
@@ -176,7 +157,6 @@ GEM
|
|||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
jmespath (1.6.2)
|
|
||||||
json (2.12.0)
|
json (2.12.0)
|
||||||
jwt (2.10.1)
|
jwt (2.10.1)
|
||||||
base64
|
base64
|
||||||
@@ -461,7 +441,6 @@ PLATFORMS
|
|||||||
x86_64-linux-musl
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
aws-sdk-s3
|
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
diff-lcs
|
diff-lcs
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
class MaterialsController < ApplicationController
|
|
||||||
def index
|
|
||||||
page = (params[:page].presence || 1).to_i
|
|
||||||
limit = (params[:limit].presence || 20).to_i
|
|
||||||
|
|
||||||
page = 1 if page < 1
|
|
||||||
limit = 1 if limit < 1
|
|
||||||
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
|
|
||||||
tag_id = params[:tag_id].presence
|
|
||||||
parent_id = params[:parent_id].presence
|
|
||||||
|
|
||||||
q = Material.includes(:tag, :created_by_user).with_attached_file
|
|
||||||
q = q.where(tag_id:) if tag_id
|
|
||||||
q = q.where(parent_id:) if parent_id
|
|
||||||
|
|
||||||
count = q.count
|
|
||||||
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
|
|
||||||
|
|
||||||
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
material =
|
|
||||||
Material
|
|
||||||
.includes(:tag)
|
|
||||||
.with_attached_file
|
|
||||||
.find_by(id: params[:id])
|
|
||||||
return head :not_found unless material
|
|
||||||
|
|
||||||
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
|
|
||||||
|
|
||||||
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
return head :unauthorized unless current_user
|
|
||||||
|
|
||||||
tag_name_raw = params[:tag].to_s.strip
|
|
||||||
file = params[:file]
|
|
||||||
url = params[:url].to_s.strip.presence
|
|
||||||
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
|
||||||
|
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
|
||||||
tag = tag_name.tag
|
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
|
||||||
|
|
||||||
material = Material.new(tag:, url:,
|
|
||||||
created_by_user: current_user,
|
|
||||||
updated_by_user: current_user)
|
|
||||||
material.file.attach(file)
|
|
||||||
|
|
||||||
if material.save
|
|
||||||
render json: MaterialRepr.base(material, host: request.base_url), status: :created
|
|
||||||
else
|
|
||||||
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
|
||||||
return head :unauthorized unless current_user
|
|
||||||
return head :forbidden unless current_user.gte_member?
|
|
||||||
|
|
||||||
material = Material.with_attached_file.find_by(id: params[:id])
|
|
||||||
return head :not_found unless material
|
|
||||||
|
|
||||||
tag_name_raw = params[:tag].to_s.strip
|
|
||||||
file = params[:file]
|
|
||||||
url = params[:url].to_s.strip.presence
|
|
||||||
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
|
|
||||||
|
|
||||||
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
|
|
||||||
tag = tag_name.tag
|
|
||||||
tag = Tag.create!(tag_name:, category: :material) unless tag
|
|
||||||
|
|
||||||
material.update!(tag:, url:, updated_by_user: current_user)
|
|
||||||
if file
|
|
||||||
material.file.attach(file)
|
|
||||||
else
|
|
||||||
material.file.purge
|
|
||||||
end
|
|
||||||
|
|
||||||
if material.save
|
|
||||||
render json: MaterialRepr.base(material, host: request.base_url)
|
|
||||||
else
|
|
||||||
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def destroy
|
|
||||||
return head :unauthorized unless current_user
|
|
||||||
return head :forbidden unless current_user.gte_member?
|
|
||||||
|
|
||||||
material = Material.find_by(id: params[:id])
|
|
||||||
return head :not_found unless material
|
|
||||||
|
|
||||||
material.discard
|
|
||||||
head :no_content
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -2,8 +2,8 @@ class TagsController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
post_id = params[:post]
|
post_id = params[:post]
|
||||||
|
|
||||||
name = params[:name].presence
|
name = params[:name].to_s.strip.presence
|
||||||
category = params[:category].presence
|
category = params[:category].to_s.strip.presence
|
||||||
post_count_between = (params[:post_count_gte].presence || -1).to_i,
|
post_count_between = (params[:post_count_gte].presence || -1).to_i,
|
||||||
(params[:post_count_lte].presence || -1).to_i
|
(params[:post_count_lte].presence || -1).to_i
|
||||||
post_count_between[0] = nil if post_count_between[0] < 0
|
post_count_between[0] = nil if post_count_between[0] < 0
|
||||||
@@ -33,11 +33,13 @@ class TagsController < ApplicationController
|
|||||||
else
|
else
|
||||||
Tag.joins(:tag_name)
|
Tag.joins(:tag_name)
|
||||||
end
|
end
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, tag_name: :wiki_page)
|
||||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
q = q.where(posts: { id: post_id }) if post_id.present?
|
||||||
|
|
||||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
if name
|
||||||
q = q.where(category:) if category
|
q = q.where('tag_names.name LIKE ?', "%#{ ActiveRecord::Base.sanitize_sql_like(name) }%")
|
||||||
|
end
|
||||||
|
q = q.where(category: category) if category
|
||||||
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
|
||||||
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
|
||||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
||||||
@@ -69,44 +71,6 @@ class TagsController < ApplicationController
|
|||||||
render json: { tags: TagRepr.base(tags), count: q.size }
|
render json: { tags: TagRepr.base(tags), count: q.size }
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_depth
|
|
||||||
parent_tag_id = params[:parent].to_i
|
|
||||||
parent_tag_id = nil if parent_tag_id <= 0
|
|
||||||
|
|
||||||
tag_ids =
|
|
||||||
if parent_tag_id
|
|
||||||
TagImplication.where(parent_tag_id:).select(:tag_id)
|
|
||||||
else
|
|
||||||
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
|
|
||||||
end
|
|
||||||
|
|
||||||
tags =
|
|
||||||
Tag
|
|
||||||
.joins(:tag_name)
|
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
.where(category: [:meme, :character, :material])
|
|
||||||
.where(id: tag_ids)
|
|
||||||
.order('tag_names.name')
|
|
||||||
.distinct
|
|
||||||
.to_a
|
|
||||||
|
|
||||||
has_children_tag_ids =
|
|
||||||
if tags.empty?
|
|
||||||
[]
|
|
||||||
else
|
|
||||||
TagImplication
|
|
||||||
.joins(:tag)
|
|
||||||
.where(parent_tag_id: tags.map(&:id),
|
|
||||||
tags: { category: [:meme, :character, :material] })
|
|
||||||
.distinct
|
|
||||||
.pluck(:parent_tag_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: tags.map { |tag|
|
|
||||||
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def autocomplete
|
def autocomplete
|
||||||
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||||
|
|
||||||
@@ -115,7 +79,7 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
alias_rows =
|
alias_rows =
|
||||||
TagName
|
TagName
|
||||||
.where('name LIKE ?', "#{ q }%")
|
.where('name LIKE ?', "#{ ActiveRecord::Base.sanitize_sql_like(q) }%")
|
||||||
.where.not(canonical_id: nil)
|
.where.not(canonical_id: nil)
|
||||||
.pluck(:canonical_id, :name)
|
.pluck(:canonical_id, :name)
|
||||||
|
|
||||||
@@ -128,14 +92,15 @@ class TagsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
base = Tag.joins(:tag_name)
|
base = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, tag_name: :wiki_page)
|
||||||
base = base.where('tags.post_count > 0') if present_only
|
base = base.where('tags.post_count > 0') if present_only
|
||||||
|
|
||||||
canonical_hit =
|
canonical_hit =
|
||||||
base
|
base
|
||||||
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
|
.where(((with_nico ? '(tags.category = ? AND tag_names.name LIKE ?) OR ' : '') +
|
||||||
'tag_names.name LIKE ?'),
|
'tag_names.name LIKE ?'),
|
||||||
*(with_nico ? ['nico', "nico:#{ q }%"] : []), "#{ q }%")
|
*(with_nico ? ['nico', "nico:#{ ActiveRecord::Base.sanitise_sql_like(q) }%"] : []),
|
||||||
|
"#{ ActiveRecord::Base.sanitise_sql_like(q) }%")
|
||||||
|
|
||||||
tags =
|
tags =
|
||||||
if canonical_ids.present?
|
if canonical_ids.present?
|
||||||
@@ -153,7 +118,7 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
tag = Tag.joins(:tag_name)
|
tag = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, tag_name: :wiki_page)
|
||||||
.find_by(id: params[:id])
|
.find_by(id: params[:id])
|
||||||
if tag
|
if tag
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag)
|
||||||
@@ -167,7 +132,7 @@ class TagsController < ApplicationController
|
|||||||
return head :bad_request if name.blank?
|
return head :bad_request if name.blank?
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
tag = Tag.joins(:tag_name)
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
.includes(:tag_name, tag_name: :wiki_page)
|
||||||
.find_by(tag_names: { name: })
|
.find_by(tag_names: { name: })
|
||||||
if tag
|
if tag
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag)
|
||||||
@@ -197,18 +162,6 @@ class TagsController < ApplicationController
|
|||||||
render json: DeerjikistRepr.many(tag.deerjikists)
|
render json: DeerjikistRepr.many(tag.deerjikists)
|
||||||
end
|
end
|
||||||
|
|
||||||
def materials_by_name
|
|
||||||
name = params[:name].to_s.strip
|
|
||||||
return head :bad_request if name.blank?
|
|
||||||
|
|
||||||
tag = Tag.joins(:tag_name)
|
|
||||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
|
||||||
.find_by(tag_names: { name: })
|
|
||||||
return head :not_found unless tag
|
|
||||||
|
|
||||||
render json: build_tag_children(tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update
|
def update
|
||||||
return head :unauthorized unless current_user
|
return head :unauthorized unless current_user
|
||||||
return head :forbidden unless current_user.gte_member?
|
return head :forbidden unless current_user.gte_member?
|
||||||
@@ -228,20 +181,4 @@ class TagsController < ApplicationController
|
|||||||
|
|
||||||
render json: TagRepr.base(tag)
|
render json: TagRepr.base(tag)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def build_tag_children(tag)
|
|
||||||
material = tag.materials.first
|
|
||||||
file = nil
|
|
||||||
content_type = nil
|
|
||||||
if material&.file&.attached?
|
|
||||||
file = rails_storage_proxy_url(material.file, only_path: false)
|
|
||||||
content_type = material.file.blob.content_type
|
|
||||||
end
|
|
||||||
|
|
||||||
TagRepr.base(tag).merge(
|
|
||||||
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
|
|
||||||
material: material.as_json&.merge(file:, content_type:))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
class TheatreCommentsController < ApplicationController
|
|
||||||
def index
|
|
||||||
no_gt = params[:no_gt].to_i
|
|
||||||
no_gt = 0 if no_gt.negative?
|
|
||||||
|
|
||||||
comments = TheatreComment
|
|
||||||
.where(theatre_id: params[:theatre_id])
|
|
||||||
.where('no > ?', no_gt)
|
|
||||||
.order(no: :desc)
|
|
||||||
|
|
||||||
render json: comments.as_json(include: { user: { only: [:id, :name] } })
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
return head :unauthorized unless current_user
|
|
||||||
|
|
||||||
content = params[:content]
|
|
||||||
return head :unprocessable_entity if content.blank?
|
|
||||||
|
|
||||||
theatre = Theatre.find_by(id: params[:theatre_id])
|
|
||||||
return head :not_found unless theatre
|
|
||||||
|
|
||||||
comment = nil
|
|
||||||
theatre.with_lock do
|
|
||||||
no = theatre.next_comment_no
|
|
||||||
comment = TheatreComment.create!(theatre:, no:, user: current_user, content:)
|
|
||||||
theatre.update!(next_comment_no: no + 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
render json: comment, status: :created
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
|
|||||||
post_started_at = theatre.current_post_started_at
|
post_started_at = theatre.current_post_started_at
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: {
|
render json: { host_flg:, post_id:, post_started_at: }
|
||||||
host_flg:, post_id:, post_started_at:,
|
|
||||||
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_post
|
def next_post
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
class Material < ApplicationRecord
|
|
||||||
include MyDiscard
|
|
||||||
|
|
||||||
default_scope -> { kept }
|
|
||||||
|
|
||||||
belongs_to :parent, class_name: 'Material', optional: true
|
|
||||||
has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify
|
|
||||||
|
|
||||||
belongs_to :tag, optional: true
|
|
||||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
|
||||||
belongs_to :updated_by_user, class_name: 'User', optional: true
|
|
||||||
|
|
||||||
has_one_attached :file, dependent: :purge
|
|
||||||
|
|
||||||
validates :tag_id, presence: true, uniqueness: true
|
|
||||||
|
|
||||||
validate :file_must_be_attached
|
|
||||||
validate :tag_must_be_material_category
|
|
||||||
|
|
||||||
def content_type
|
|
||||||
return nil unless file&.attached?
|
|
||||||
|
|
||||||
file.blob.content_type
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def file_must_be_attached
|
|
||||||
return if url.present? || file.attached?
|
|
||||||
|
|
||||||
errors.add(:url, 'URL かファイルのどちらかは必須です.')
|
|
||||||
end
|
|
||||||
|
|
||||||
def tag_must_be_material_category
|
|
||||||
return if tag.blank? || tag.character? || tag.material?
|
|
||||||
|
|
||||||
errors.add(:tag, '素材カテゴリのタグを指定してください.')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -31,7 +31,6 @@ class Tag < ApplicationRecord
|
|||||||
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
||||||
|
|
||||||
has_many :deerjikists, dependent: :delete_all
|
has_many :deerjikists, dependent: :delete_all
|
||||||
has_many :materials
|
|
||||||
|
|
||||||
belongs_to :tag_name
|
belongs_to :tag_name
|
||||||
delegate :wiki_page, to: :tag_name
|
delegate :wiki_page, to: :tag_name
|
||||||
@@ -73,8 +72,6 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def has_wiki = wiki_page.present?
|
def has_wiki = wiki_page.present?
|
||||||
|
|
||||||
def material_id = materials.first&.id
|
|
||||||
|
|
||||||
def self.tagme
|
def self.tagme
|
||||||
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
|
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
class TheatreComment < ApplicationRecord
|
class TheatreComment < ApplicationRecord
|
||||||
include Discard::Model
|
include MyDiscard
|
||||||
|
|
||||||
self.primary_key = :theatre_id, :no
|
self.primary_key = :theatre_id, :no
|
||||||
|
|
||||||
belongs_to :theatre
|
belongs_to :theatre
|
||||||
belongs_to :user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
|
|
||||||
module MaterialRepr
|
|
||||||
BASE = { only: [:id, :url, :created_at, :updated_at],
|
|
||||||
methods: [:content_type],
|
|
||||||
include: { tag: TagRepr::BASE,
|
|
||||||
created_by_user: UserRepr::BASE,
|
|
||||||
updated_by_user: UserRepr::BASE } }.freeze
|
|
||||||
|
|
||||||
module_function
|
|
||||||
|
|
||||||
def base material, host:
|
|
||||||
material.as_json(BASE).merge(
|
|
||||||
file: if material.file.attached?
|
|
||||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(
|
|
||||||
material.file, host:)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def many materials, host:
|
|
||||||
materials.map { |m| base(m, host:) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
module TagRepr
|
module TagRepr
|
||||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
||||||
methods: [:name, :has_wiki, :material_id] }.freeze
|
methods: [:name, :has_wiki] }.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ Rails.application.configure do
|
|||||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||||
# config.asset_host = "http://assets.example.com"
|
# config.asset_host = "http://assets.example.com"
|
||||||
|
|
||||||
config.active_storage.service = :r2
|
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||||
|
config.active_storage.service = :local
|
||||||
|
|
||||||
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
||||||
config.assume_ssl = true
|
config.assume_ssl = true
|
||||||
|
|||||||
@@ -50,6 +50,4 @@ Rails.application.configure do
|
|||||||
|
|
||||||
# Raise error when a before_action's only/except options reference missing actions.
|
# Raise error when a before_action's only/except options reference missing actions.
|
||||||
config.action_controller.raise_on_missing_callback_actions = true
|
config.action_controller.raise_on_missing_callback_actions = true
|
||||||
|
|
||||||
Rails.application.routes.default_url_options[:host] = 'www.example.com'
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,11 +9,9 @@ Rails.application.routes.draw do
|
|||||||
resources :tags, only: [:index, :show, :update] do
|
resources :tags, only: [:index, :show, :update] do
|
||||||
collection do
|
collection do
|
||||||
get :autocomplete
|
get :autocomplete
|
||||||
get :'with-depth', action: :with_depth
|
|
||||||
|
|
||||||
scope :name do
|
scope :name do
|
||||||
get ':name/deerjikists', action: :deerjikists_by_name
|
get ':name/deerjikists', action: :deerjikists_by_name
|
||||||
get ':name/materials', action: :materials_by_name
|
|
||||||
get ':name', action: :show_by_name
|
get ':name', action: :show_by_name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -80,9 +78,5 @@ Rails.application.routes.draw do
|
|||||||
put :watching
|
put :watching
|
||||||
patch :next_post
|
patch :next_post
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :comments, controller: :theatre_comments, only: [:index, :create]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :materials, only: [:index, :show, :create, :update, :destroy]
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,10 +6,29 @@ local:
|
|||||||
service: Disk
|
service: Disk
|
||||||
root: <%= Rails.root.join("storage") %>
|
root: <%= Rails.root.join("storage") %>
|
||||||
|
|
||||||
r2:
|
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||||
service: S3
|
# amazon:
|
||||||
endpoint: <%= ENV['R2_ENDPOINT'] %>
|
# service: S3
|
||||||
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %>
|
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
||||||
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
|
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
||||||
bucket: <%= ENV['R2_BUCKET'] %>
|
# region: us-east-1
|
||||||
region: auto
|
# bucket: your_own_bucket-<%= Rails.env %>
|
||||||
|
|
||||||
|
# Remember not to checkin your GCS keyfile to a repository
|
||||||
|
# google:
|
||||||
|
# service: GCS
|
||||||
|
# project: your_project
|
||||||
|
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
|
||||||
|
# bucket: your_own_bucket-<%= Rails.env %>
|
||||||
|
|
||||||
|
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
|
||||||
|
# microsoft:
|
||||||
|
# service: AzureStorage
|
||||||
|
# storage_account_name: your_account_name
|
||||||
|
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
|
||||||
|
# container: your_container_name-<%= Rails.env %>
|
||||||
|
|
||||||
|
# mirror:
|
||||||
|
# service: Mirror
|
||||||
|
# primary: local
|
||||||
|
# mirrors: [ amazon, google, microsoft ]
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
class CreateMaterials < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :materials do |t|
|
|
||||||
t.string :url
|
|
||||||
t.references :parent, index: true, foreign_key: { to_table: :materials }
|
|
||||||
t.references :tag, index: true, foreign_key: true
|
|
||||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
|
||||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
|
||||||
t.timestamps
|
|
||||||
t.datetime :discarded_at, index: true
|
|
||||||
t.virtual :active_url, type: :string,
|
|
||||||
as: 'IF(discarded_at IS NULL, url, NULL)',
|
|
||||||
stored: false
|
|
||||||
|
|
||||||
t.index :active_url, unique: true
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table :material_versions do |t|
|
|
||||||
t.references :material, null: false, foreign_key: true
|
|
||||||
t.integer :version_no, null: false
|
|
||||||
t.string :url, index: true
|
|
||||||
t.references :parent, index: true, foreign_key: { to_table: :materials }
|
|
||||||
t.references :tag, index: true, foreign_key: true
|
|
||||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
|
||||||
t.references :updated_by_user, foreign_key: { to_table: :users }
|
|
||||||
t.timestamps
|
|
||||||
t.datetime :discarded_at, index: true
|
|
||||||
|
|
||||||
t.index [:material_id, :version_no],
|
|
||||||
unique: true,
|
|
||||||
name: 'index_material_versions_on_material_id_and_version_no'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
Generated
+1
-65
@@ -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_03_29_034700) do
|
ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) 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
|
||||||
@@ -56,45 +56,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.bigint "material_id", null: false
|
|
||||||
t.integer "version_no", null: false
|
|
||||||
t.string "url"
|
|
||||||
t.bigint "parent_id"
|
|
||||||
t.bigint "tag_id"
|
|
||||||
t.bigint "created_by_user_id"
|
|
||||||
t.bigint "updated_by_user_id"
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.datetime "discarded_at"
|
|
||||||
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
|
|
||||||
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
|
|
||||||
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
|
|
||||||
t.index ["material_id"], name: "index_material_versions_on_material_id"
|
|
||||||
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
|
|
||||||
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
|
|
||||||
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
|
|
||||||
t.index ["url"], name: "index_material_versions_on_url"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.string "url"
|
|
||||||
t.bigint "parent_id"
|
|
||||||
t.bigint "tag_id"
|
|
||||||
t.bigint "created_by_user_id"
|
|
||||||
t.bigint "updated_by_user_id"
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.datetime "discarded_at"
|
|
||||||
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
|
|
||||||
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
|
|
||||||
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
|
|
||||||
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
|
|
||||||
t.index ["parent_id"], name: "index_materials_on_parent_id"
|
|
||||||
t.index ["tag_id"], name: "index_materials_on_tag_id"
|
|
||||||
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.bigint "nico_tag_id", null: false
|
t.bigint "nico_tag_id", null: false
|
||||||
t.bigint "tag_id", null: false
|
t.bigint "tag_id", null: false
|
||||||
@@ -278,19 +239,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
|
||||||
t.bigint "wiki_page_id", null: false
|
|
||||||
t.integer "no", null: false
|
|
||||||
t.string "alt_text"
|
|
||||||
t.binary "sha256", limit: 32, null: false
|
|
||||||
t.bigint "created_by_user_id", null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
|
|
||||||
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
|
|
||||||
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||||
t.string "sha256", limit: 64, null: false
|
t.string "sha256", limit: 64, null: false
|
||||||
t.text "body", null: false
|
t.text "body", null: false
|
||||||
@@ -306,7 +254,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.datetime "discarded_at"
|
t.datetime "discarded_at"
|
||||||
t.integer "next_asset_no", default: 1, null: false
|
|
||||||
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
|
||||||
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
||||||
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
||||||
@@ -345,15 +292,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||||
add_foreign_key "material_versions", "materials"
|
|
||||||
add_foreign_key "material_versions", "materials", column: "parent_id"
|
|
||||||
add_foreign_key "material_versions", "tags"
|
|
||||||
add_foreign_key "material_versions", "users", column: "created_by_user_id"
|
|
||||||
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
|
|
||||||
add_foreign_key "materials", "materials", column: "parent_id"
|
|
||||||
add_foreign_key "materials", "tags"
|
|
||||||
add_foreign_key "materials", "users", column: "created_by_user_id"
|
|
||||||
add_foreign_key "materials", "users", column: "updated_by_user_id"
|
|
||||||
add_foreign_key "nico_tag_relations", "tags"
|
add_foreign_key "nico_tag_relations", "tags"
|
||||||
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
|
||||||
add_foreign_key "post_similarities", "posts"
|
add_foreign_key "post_similarities", "posts"
|
||||||
@@ -382,8 +320,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_29_034700) do
|
|||||||
add_foreign_key "user_ips", "users"
|
add_foreign_key "user_ips", "users"
|
||||||
add_foreign_key "user_post_views", "posts"
|
add_foreign_key "user_post_views", "posts"
|
||||||
add_foreign_key "user_post_views", "users"
|
add_foreign_key "user_post_views", "users"
|
||||||
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
|
|
||||||
add_foreign_key "wiki_assets", "wiki_pages"
|
|
||||||
add_foreign_key "wiki_pages", "tag_names"
|
add_foreign_key "wiki_pages", "tag_names"
|
||||||
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
add_foreign_key "wiki_pages", "users", column: "created_user_id"
|
||||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :theatre_comment do
|
|
||||||
association :theatre
|
|
||||||
association :user
|
|
||||||
sequence (:no) { |n| n }
|
|
||||||
content { 'test comment' }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
FactoryBot.define do
|
|
||||||
factory :theatre do
|
|
||||||
name { 'Test Theatre' }
|
|
||||||
kind { 1 }
|
|
||||||
opens_at { Time.current }
|
|
||||||
closes_at { 1.day.from_now }
|
|
||||||
next_comment_no { 1 }
|
|
||||||
|
|
||||||
association :created_by_user, factory: :user
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'Materials API', type: :request do
|
|
||||||
let!(:member_user) { create(:user, :member) }
|
|
||||||
let!(:guest_user) { create(:user) }
|
|
||||||
|
|
||||||
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
|
||||||
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
|
|
||||||
end
|
|
||||||
|
|
||||||
def response_materials
|
|
||||||
json.fetch('materials')
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
|
|
||||||
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
|
|
||||||
material.file.attach(file) if file
|
|
||||||
material.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /materials' do
|
|
||||||
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
|
|
||||||
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
|
|
||||||
|
|
||||||
let!(:material_a) do
|
|
||||||
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:material_b) do
|
|
||||||
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
old_time = Time.zone.local(2026, 3, 29, 1, 0, 0)
|
|
||||||
new_time = Time.zone.local(2026, 3, 29, 2, 0, 0)
|
|
||||||
|
|
||||||
material_a.update_columns(created_at: old_time, updated_at: old_time)
|
|
||||||
material_b.update_columns(created_at: new_time, updated_at: new_time)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns materials with count and metadata' do
|
|
||||||
get '/materials'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include('materials', 'count')
|
|
||||||
expect(response_materials).to be_an(Array)
|
|
||||||
expect(json['count']).to eq(2)
|
|
||||||
|
|
||||||
row = response_materials.find { |m| m['id'] == material_b.id }
|
|
||||||
expect(row).to be_present
|
|
||||||
expect(row['tag']).to include(
|
|
||||||
'id' => tag_b.id,
|
|
||||||
'name' => 'material_index_b',
|
|
||||||
'category' => 'material'
|
|
||||||
)
|
|
||||||
expect(row['created_by_user']).to include(
|
|
||||||
'id' => member_user.id,
|
|
||||||
'name' => member_user.name
|
|
||||||
)
|
|
||||||
expect(row['content_type']).to eq('image/png')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters materials by tag_id' do
|
|
||||||
get '/materials', params: { tag_id: material_a.tag_id }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json['count']).to eq(1)
|
|
||||||
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'filters materials by parent_id' do
|
|
||||||
get '/materials', params: { parent_id: material_a.id }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json['count']).to eq(1)
|
|
||||||
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'paginates and keeps total count' do
|
|
||||||
get '/materials', params: { page: 2, limit: 1 }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json['count']).to eq(2)
|
|
||||||
expect(response_materials.size).to eq(1)
|
|
||||||
expect(response_materials.first['id']).to eq(material_a.id)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'normalises invalid page and limit' do
|
|
||||||
get '/materials', params: { page: 0, limit: 0 }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json['count']).to eq(2)
|
|
||||||
expect(response_materials.size).to eq(1)
|
|
||||||
expect(response_materials.first['id']).to eq(material_b.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /materials/:id' do
|
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
|
|
||||||
let!(:material) do
|
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns a material with file, tag, and content_type' do
|
|
||||||
get "/materials/#{ material.id }"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json).to include(
|
|
||||||
'id' => material.id,
|
|
||||||
'content_type' => 'image/png'
|
|
||||||
)
|
|
||||||
expect(json['file']).to be_present
|
|
||||||
expect(json['tag']).to include(
|
|
||||||
'id' => tag.id,
|
|
||||||
'name' => 'material_show',
|
|
||||||
'category' => 'material'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 404 when material does not exist' do
|
|
||||||
get '/materials/999999999'
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST /materials' do
|
|
||||||
context 'when not logged in' do
|
|
||||||
before { sign_out }
|
|
||||||
|
|
||||||
it 'returns 401' do
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_unauthorized',
|
|
||||||
file: dummy_upload
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when logged in' do
|
|
||||||
before { sign_in_as(guest_user) }
|
|
||||||
|
|
||||||
it 'returns 400 when tag is blank' do
|
|
||||||
post '/materials', params: { tag: ' ', file: dummy_upload }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 400 when both file and url are blank' do
|
|
||||||
post '/materials', params: { tag: 'material_create_blank' }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates a material with an attached file' do
|
|
||||||
expect do
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_new',
|
|
||||||
file: dummy_upload(filename: 'created.png')
|
|
||||||
}
|
|
||||||
end.to change(Material, :count).by(1)
|
|
||||||
.and change(Tag, :count).by(1)
|
|
||||||
.and change(TagName, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
material = Material.order(:id).last
|
|
||||||
expect(material.tag.name).to eq('material_create_new')
|
|
||||||
expect(material.tag.category).to eq('material')
|
|
||||||
expect(material.created_by_user).to eq(guest_user)
|
|
||||||
expect(material.updated_by_user).to eq(guest_user)
|
|
||||||
expect(material.file.attached?).to be(true)
|
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
|
||||||
expect(json.dig('tag', 'name')).to eq('material_create_new')
|
|
||||||
expect(json['content_type']).to eq('image/png')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 422 when the existing tag is not material/character' do
|
|
||||||
general_tag_name = TagName.create!(name: 'material_create_general_tag')
|
|
||||||
Tag.create!(tag_name: general_tag_name, category: :general)
|
|
||||||
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_general_tag',
|
|
||||||
file: dummy_upload
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'persists url-only material' do
|
|
||||||
expect do
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_url_only',
|
|
||||||
url: 'https://example.com/material-source'
|
|
||||||
}
|
|
||||||
end.to change(Material, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
material = Material.order(:id).last
|
|
||||||
expect(material.tag.name).to eq('material_create_url_only')
|
|
||||||
expect(material.url).to eq('https://example.com/material-source')
|
|
||||||
expect(material.file.attached?).to be(false)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns the original url for url-only material' do
|
|
||||||
post '/materials', params: {
|
|
||||||
tag: 'material_create_url_only_response',
|
|
||||||
url: 'https://example.com/material-source'
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
expect(json['url']).to eq('https://example.com/material-source')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'PUT /materials/:id' do
|
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
|
|
||||||
let!(:material) do
|
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when not logged in' do
|
|
||||||
before { sign_out }
|
|
||||||
|
|
||||||
it 'returns 401' do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_new',
|
|
||||||
file: dummy_upload(filename: 'new.png')
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when logged in but not member' do
|
|
||||||
before { sign_in_as(guest_user) }
|
|
||||||
|
|
||||||
it 'returns 403' do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_new',
|
|
||||||
file: dummy_upload(filename: 'new.png')
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:forbidden)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when member' do
|
|
||||||
before { sign_in_as(member_user) }
|
|
||||||
|
|
||||||
it 'returns 404 when material does not exist' do
|
|
||||||
put '/materials/999999999', params: {
|
|
||||||
tag: 'material_update_missing',
|
|
||||||
file: dummy_upload
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 400 when tag is blank' do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: ' ',
|
|
||||||
file: dummy_upload
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 400 when both file and url are blank' do
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_no_payload'
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'updates tag, url, file, and updated_by_user' do
|
|
||||||
old_blob_id = material.file.blob.id
|
|
||||||
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_new',
|
|
||||||
url: 'https://example.com/updated-source',
|
|
||||||
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
material.reload
|
|
||||||
expect(material.tag.name).to eq('material_update_new')
|
|
||||||
expect(material.tag.category).to eq('material')
|
|
||||||
expect(material.url).to eq('https://example.com/updated-source')
|
|
||||||
expect(material.updated_by_user).to eq(member_user)
|
|
||||||
expect(material.file.attached?).to be(true)
|
|
||||||
expect(material.file.blob.id).not_to eq(old_blob_id)
|
|
||||||
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
|
|
||||||
expect(material.file.blob.content_type).to eq('image/jpeg')
|
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
|
||||||
expect(json['file']).to be_present
|
|
||||||
expect(json['content_type']).to eq('image/jpeg')
|
|
||||||
expect(json.dig('tag', 'name')).to eq('material_update_new')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'purges the existing file when file is omitted and url is provided' do
|
|
||||||
old_blob_id = material.file.blob.id
|
|
||||||
|
|
||||||
put "/materials/#{ material.id }", params: {
|
|
||||||
tag: 'material_update_remove_file',
|
|
||||||
url: 'https://example.com/updated-source'
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
material.reload
|
|
||||||
expect(material.tag.name).to eq('material_update_remove_file')
|
|
||||||
expect(material.url).to eq('https://example.com/updated-source')
|
|
||||||
expect(material.updated_by_user).to eq(member_user)
|
|
||||||
expect(material.file.attached?).to be(false)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
ActiveStorage::Blob.where(id: old_blob_id).exists?
|
|
||||||
).to be(false)
|
|
||||||
|
|
||||||
expect(json['id']).to eq(material.id)
|
|
||||||
expect(json['file']).to be_nil
|
|
||||||
expect(json['content_type']).to be_nil
|
|
||||||
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
|
|
||||||
expect(json['url']).to eq('https://example.com/updated-source')
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'DELETE /materials/:id' do
|
|
||||||
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
|
|
||||||
let!(:material) do
|
|
||||||
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when not logged in' do
|
|
||||||
before { sign_out }
|
|
||||||
|
|
||||||
it 'returns 401' do
|
|
||||||
delete "/materials/#{ material.id }"
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when logged in but not member' do
|
|
||||||
before { sign_in_as(guest_user) }
|
|
||||||
|
|
||||||
it 'returns 403' do
|
|
||||||
delete "/materials/#{ material.id }"
|
|
||||||
expect(response).to have_http_status(:forbidden)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'when member' do
|
|
||||||
before { sign_in_as(member_user) }
|
|
||||||
|
|
||||||
it 'returns 404 when material does not exist' do
|
|
||||||
delete '/materials/999999999'
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'discards the material and returns 204' do
|
|
||||||
delete "/materials/#{ material.id }"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
|
||||||
expect(Material.find_by(id: material.id)).to be_nil
|
|
||||||
expect(Material.with_discarded.find(material.id)).to be_discarded
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -19,17 +19,6 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
response_tags.map { |t| t.fetch('name') }
|
response_tags.map { |t| t.fetch('name') }
|
||||||
end
|
end
|
||||||
|
|
||||||
def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
|
|
||||||
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
|
|
||||||
Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
|
|
||||||
material.file.attach(dummy_material_upload(filename:, type:)) if filename
|
|
||||||
material.save!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /tags' do
|
describe 'GET /tags' do
|
||||||
it 'returns tags with count and metadata' do
|
it 'returns tags with count and metadata' do
|
||||||
get '/tags'
|
get '/tags'
|
||||||
@@ -370,144 +359,4 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'GET /tags/with-depth' do
|
|
||||||
let!(:root_meme) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:root_material) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:hidden_general_root) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:child_character) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:grandchild_material) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:child_general) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
TagImplication.create!(parent_tag: root_meme, tag: child_character)
|
|
||||||
TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
|
|
||||||
TagImplication.create!(parent_tag: root_material, tag: child_general)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns only visible root tags and visible has_children flags' do
|
|
||||||
get '/tags/with-depth'
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { |t| t['name'] }).to eq([
|
|
||||||
'depth_a_root_meme',
|
|
||||||
'depth_b_root_material'
|
|
||||||
])
|
|
||||||
|
|
||||||
meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
|
|
||||||
material_row = json.find { |t| t['name'] == 'depth_b_root_material' }
|
|
||||||
|
|
||||||
expect(meme_row['has_children']).to eq(true)
|
|
||||||
expect(meme_row['children']).to eq([])
|
|
||||||
|
|
||||||
expect(material_row['has_children']).to eq(false)
|
|
||||||
expect(material_row['children']).to eq([])
|
|
||||||
|
|
||||||
expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns children of the specified parent' do
|
|
||||||
get '/tags/with-depth', params: { parent: root_meme.id }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])
|
|
||||||
|
|
||||||
row = json.first
|
|
||||||
expect(row['category']).to eq('character')
|
|
||||||
expect(row['has_children']).to eq(true)
|
|
||||||
expect(row['children']).to eq([])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /tags/name/:name/materials' do
|
|
||||||
let!(:material_user) { create_member_user! }
|
|
||||||
|
|
||||||
let!(:root_tag) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:child_a_tag) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:child_b_tag) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:grandchild_tag) do
|
|
||||||
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:root_material) do
|
|
||||||
create_material(root_tag, user: material_user, filename: 'root.png')
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:child_a_material) do
|
|
||||||
create_material(child_a_tag, user: material_user, filename: 'child_a.png')
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:grandchild_material) do
|
|
||||||
create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
|
||||||
TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
|
|
||||||
TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
|
|
||||||
TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns a tag tree with nested materials sorted by child name' do
|
|
||||||
get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
expect(json).to include(
|
|
||||||
'id' => root_tag.id,
|
|
||||||
'name' => 'materials_root',
|
|
||||||
'category' => 'material'
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(json['material']).to be_present
|
|
||||||
expect(json.dig('material', 'id')).to eq(root_material.id)
|
|
||||||
expect(json.dig('material', 'file')).to be_present
|
|
||||||
expect(json.dig('material', 'content_type')).to eq('image/png')
|
|
||||||
|
|
||||||
expect(json['children'].map { |t| t['name'] }).to eq([
|
|
||||||
'materials_child_a',
|
|
||||||
'materials_child_b'
|
|
||||||
])
|
|
||||||
|
|
||||||
child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
|
|
||||||
child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }
|
|
||||||
|
|
||||||
expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
|
|
||||||
expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
|
|
||||||
expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)
|
|
||||||
|
|
||||||
expect(child_b['material']).to be_nil
|
|
||||||
expect(child_b['children']).to eq([])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns 404 when the tag does not exist' do
|
|
||||||
get '/tags/name/no_such_tag_12345/materials'
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe 'TheatreComments', type: :request do
|
|
||||||
def sign_in_as(user)
|
|
||||||
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET /theatres/:theatre_id/comments' do
|
|
||||||
let(:theatre) { create(:theatre) }
|
|
||||||
let(:other_theatre) { create(:theatre) }
|
|
||||||
let(:alice) { create(:user, name: 'Alice') }
|
|
||||||
let(:bob) { create(:user, name: 'Bob') }
|
|
||||||
|
|
||||||
let!(:comment_3) do
|
|
||||||
create(
|
|
||||||
:theatre_comment,
|
|
||||||
theatre: theatre,
|
|
||||||
no: 3,
|
|
||||||
user: alice,
|
|
||||||
content: 'third comment'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:comment_1) do
|
|
||||||
create(
|
|
||||||
:theatre_comment,
|
|
||||||
theatre: theatre,
|
|
||||||
no: 1,
|
|
||||||
user: alice,
|
|
||||||
content: 'first comment'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:comment_2) do
|
|
||||||
create(
|
|
||||||
:theatre_comment,
|
|
||||||
theatre: theatre,
|
|
||||||
no: 2,
|
|
||||||
user: bob,
|
|
||||||
content: 'second comment'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
let!(:other_comment) do
|
|
||||||
create(
|
|
||||||
:theatre_comment,
|
|
||||||
theatre: other_theatre,
|
|
||||||
no: 1,
|
|
||||||
user: bob,
|
|
||||||
content: 'other theatre comment'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'theatre_id で絞り込み、no_gt より大きいものを no 降順で返す' do
|
|
||||||
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2])
|
|
||||||
expect(response.parsed_body.map { |row| row['content'] }).to eq([
|
|
||||||
'third comment',
|
|
||||||
'second comment'
|
|
||||||
])
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'user は id と name だけを含む' do
|
|
||||||
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
|
|
||||||
expect(response.parsed_body.first['user']).to eq({
|
|
||||||
'id' => alice.id,
|
|
||||||
'name' => 'Alice'
|
|
||||||
})
|
|
||||||
expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name')
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'no_gt が負数なら 0 として扱う' do
|
|
||||||
get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 }
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
|
||||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST /theatres/:theatre_id/comments' do
|
|
||||||
let(:user) { create(:user, name: 'Alice') }
|
|
||||||
let(:theatre) { create(:theatre, next_comment_no: 2) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
create(
|
|
||||||
:theatre_comment,
|
|
||||||
theatre: theatre,
|
|
||||||
no: 1,
|
|
||||||
user: user,
|
|
||||||
content: 'existing comment'
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
it '未ログインなら 401 を返す' do
|
|
||||||
expect {
|
|
||||||
post "/theatres/#{theatre.id}/comments", params: { content: 'hello' }
|
|
||||||
}.not_to change(TheatreComment, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'content が blank なら 422 を返す' do
|
|
||||||
sign_in_as(user)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post "/theatres/#{theatre.id}/comments", params: { content: ' ' }
|
|
||||||
}.not_to change(TheatreComment, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'theatre が存在しなければ 404 を返す' do
|
|
||||||
sign_in_as(user)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post '/theatres/999999/comments', params: { content: 'hello' }
|
|
||||||
}.not_to change(TheatreComment, :count)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'コメントを作成し、user を紐づけ、next_comment_no を進める' do
|
|
||||||
sign_in_as(user)
|
|
||||||
|
|
||||||
expect {
|
|
||||||
post "/theatres/#{theatre.id}/comments", params: { content: 'new comment' }
|
|
||||||
}.to change(TheatreComment, :count).by(1)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:created)
|
|
||||||
|
|
||||||
comment = TheatreComment.find_by!(theatre: theatre, no: 2)
|
|
||||||
|
|
||||||
expect(comment.user).to eq(user)
|
|
||||||
expect(comment.content).to eq('new comment')
|
|
||||||
expect(theatre.reload.next_comment_no).to eq(3)
|
|
||||||
|
|
||||||
expect(response.parsed_body.slice('theatre_id', 'no', 'user_id', 'content')).to eq({
|
|
||||||
'theatre_id' => theatre.id,
|
|
||||||
'no' => 2,
|
|
||||||
'user_id' => user.id,
|
|
||||||
'content' => 'new comment'
|
|
||||||
})
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -117,18 +117,11 @@ RSpec.describe 'Theatres API', type: :request do
|
|||||||
expect(theatre.host_user_id).to eq(member.id)
|
expect(theatre.host_user_id).to eq(member.id)
|
||||||
expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now)
|
expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now)
|
||||||
|
|
||||||
expect(json).to include(
|
expect(json).to eq(
|
||||||
'host_flg' => true,
|
'host_flg' => true,
|
||||||
'post_id' => nil,
|
'post_id' => nil,
|
||||||
'post_started_at' => nil
|
'post_started_at' => nil
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(json.fetch('watching_users')).to contain_exactly(
|
|
||||||
{
|
|
||||||
'id' => member.id,
|
|
||||||
'name' => 'member user'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -174,22 +167,11 @@ RSpec.describe 'Theatres API', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(theatre.reload.host_user_id).to eq(other_user.id)
|
expect(theatre.reload.host_user_id).to eq(other_user.id)
|
||||||
|
|
||||||
expect(json).to include(
|
expect(json).to eq(
|
||||||
'host_flg' => false,
|
'host_flg' => false,
|
||||||
'post_id' => nil,
|
'post_id' => nil,
|
||||||
'post_started_at' => nil
|
'post_started_at' => nil
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(json.fetch('watching_users')).to contain_exactly(
|
|
||||||
{
|
|
||||||
'id' => member.id,
|
|
||||||
'name' => 'member user'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'id' => other_user.id,
|
|
||||||
'name' => 'other user'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
+2
-13
@@ -10,11 +10,6 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
|||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { apiPost, isApiError } from '@/lib/api'
|
import { apiPost, isApiError } from '@/lib/api'
|
||||||
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
|
|
||||||
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
|
|
||||||
import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|
||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
|
||||||
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
|
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||||
@@ -46,7 +41,7 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
return (
|
return (
|
||||||
<LayoutGroup id="gallery-shared">
|
<LayoutGroup id="gallery-shared">
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<Routes location={location}>
|
<Routes location={location} key={location.pathname}>
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="/posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
@@ -56,12 +51,6 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
|||||||
<Route path="/tags" element={<TagListPage/>}/>
|
<Route path="/tags" element={<TagListPage/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
|
||||||
<Route index element={<MaterialListPage/>}/>
|
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
|
||||||
</Route>
|
|
||||||
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */}
|
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
@@ -131,7 +120,7 @@ export default (() => {
|
|||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="flex flex-col h-dvh w-screen">
|
<div className="flex flex-col h-screen w-screen">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import TagLink from '@/components/TagLink'
|
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
|
||||||
|
|
||||||
type TagWithDepth = Tag & {
|
|
||||||
hasChildren: boolean
|
|
||||||
children: TagWithDepth[] }
|
|
||||||
|
|
||||||
|
|
||||||
const setChildrenById = (
|
|
||||||
tags: TagWithDepth[],
|
|
||||||
targetId: number,
|
|
||||||
children: TagWithDepth[],
|
|
||||||
): TagWithDepth[] => (
|
|
||||||
tags.map (tag => {
|
|
||||||
if (tag.id === targetId)
|
|
||||||
return { ...tag, children }
|
|
||||||
|
|
||||||
if (tag.children.length === 0)
|
|
||||||
return tag
|
|
||||||
|
|
||||||
return { ...tag,
|
|
||||||
children: (setChildrenById (tag.children, targetId, children)
|
|
||||||
.filter (t => t.category !== 'meme' || t.hasChildren)) }
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
const [tags, setTags] = useState<TagWithDepth[]> ([])
|
|
||||||
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
|
|
||||||
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
void (async () => {
|
|
||||||
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
|
|
||||||
.filter (t => t.category !== 'meme' || t.hasChildren))
|
|
||||||
}) ()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
|
|
||||||
ts.map (t => (
|
|
||||||
<Fragment key={t.id}>
|
|
||||||
<li>
|
|
||||||
<div className="flex">
|
|
||||||
<div className="flex-none w-4">
|
|
||||||
{t.hasChildren && (
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
onClick={async e => {
|
|
||||||
e.preventDefault ()
|
|
||||||
if (!(tagFetchedFlags[t.id]))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const data =
|
|
||||||
await apiGet<TagWithDepth[]> (
|
|
||||||
'/tags/with-depth', { params: { parent: String (t.id) } })
|
|
||||||
setTags (prev => setChildrenById (prev, t.id, data))
|
|
||||||
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
|
|
||||||
}}>
|
|
||||||
{openTags[t.id] ? <>−</> : '+'}
|
|
||||||
</a>)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 truncate">
|
|
||||||
<TagLink
|
|
||||||
tag={t}
|
|
||||||
nestLevel={nestLevel}
|
|
||||||
title={t.name}
|
|
||||||
withCount={false}
|
|
||||||
withWiki={false}
|
|
||||||
to={`/materials?tag=${ encodeURIComponent (t.name) }`}/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
{openTags[t.id] && renderTags (t.children, nestLevel + 1)}
|
|
||||||
</Fragment>)))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarComponent>
|
|
||||||
<ul>
|
|
||||||
{renderTags (tags)}
|
|
||||||
</ul>
|
|
||||||
</SidebarComponent>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -71,8 +71,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
|| ev.metaKey
|
|| ev.metaKey
|
||||||
|| ev.ctrlKey
|
|| ev.ctrlKey
|
||||||
|| ev.shiftKey
|
|| ev.shiftKey
|
||||||
|| ev.altKey
|
|| ev.altKey)
|
||||||
|| (rest.target && rest.target !== '_self'))
|
|
||||||
return
|
return
|
||||||
|
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||||
|
import { apiGet } from '@/lib/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
||||||
@@ -10,7 +13,8 @@ type CommonProps = {
|
|||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel?: number
|
nestLevel?: number
|
||||||
withWiki?: boolean
|
withWiki?: boolean
|
||||||
withCount?: boolean }
|
withCount?: boolean
|
||||||
|
prefetch?: boolean }
|
||||||
|
|
||||||
type PropsWithLink =
|
type PropsWithLink =
|
||||||
& CommonProps
|
& CommonProps
|
||||||
@@ -32,7 +36,37 @@ export default (({ tag,
|
|||||||
linkFlg = true,
|
linkFlg = true,
|
||||||
withWiki = true,
|
withWiki = true,
|
||||||
withCount = true,
|
withCount = true,
|
||||||
|
prefetch = false,
|
||||||
...props }: Props) => {
|
...props }: Props) => {
|
||||||
|
const [havingWiki, setHavingWiki] = useState (true)
|
||||||
|
|
||||||
|
const wikiExists = async (tag: Tag) => {
|
||||||
|
if ('hasWiki' in tag)
|
||||||
|
{
|
||||||
|
setHavingWiki (tag.hasWiki)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = (tag as Tag).name
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
|
||||||
|
setHavingWiki (true)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
setHavingWiki (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (!(linkFlg) || !(withWiki))
|
||||||
|
return
|
||||||
|
|
||||||
|
wikiExists (tag)
|
||||||
|
}, [tag.name, linkFlg, withWiki])
|
||||||
|
|
||||||
const spanClass = cn (
|
const spanClass = cn (
|
||||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||||
@@ -45,39 +79,19 @@ export default (({ tag,
|
|||||||
<>
|
<>
|
||||||
{(linkFlg && withWiki) && (
|
{(linkFlg && withWiki) && (
|
||||||
<span className="mr-1">
|
<span className="mr-1">
|
||||||
{(tag.materialId != null || tag.hasWiki)
|
{havingWiki
|
||||||
? (
|
? (
|
||||||
tag.materialId == null
|
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||||
? (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
|
||||||
className={linkClass}>
|
className={linkClass}>
|
||||||
?
|
?
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
<PrefetchLink
|
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||||
to={`/materials/${ tag.materialId }`}
|
|
||||||
className={linkClass}>
|
|
||||||
?
|
|
||||||
</PrefetchLink>))
|
|
||||||
: (
|
|
||||||
['character', 'material'].includes (tag.category)
|
|
||||||
? (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
|
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
|
||||||
title={`${ tag.name } 素材情報が存在しません.`}>
|
|
||||||
!
|
|
||||||
</PrefetchLink>)
|
|
||||||
: (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } Wiki が存在しません.`}>
|
title={`${ tag.name } Wiki が存在しません.`}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>))}
|
</PrefetchLink>)}
|
||||||
</span>)}
|
</span>)}
|
||||||
{nestLevel > 0 && (
|
{nestLevel > 0 && (
|
||||||
<span
|
<span
|
||||||
@@ -94,7 +108,14 @@ export default (({ tag,
|
|||||||
</>)}
|
</>)}
|
||||||
{linkFlg
|
{linkFlg
|
||||||
? (
|
? (
|
||||||
<PrefetchLink
|
prefetch
|
||||||
|
? <PrefetchLink
|
||||||
|
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||||
|
className={linkClass}
|
||||||
|
{...props}>
|
||||||
|
{tag.name}
|
||||||
|
</PrefetchLink>
|
||||||
|
: <PrefetchLink
|
||||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default (({ posts, onClick }: Props) => {
|
|||||||
tags[cat].map (tag => (
|
tags[cat].map (tag => (
|
||||||
<li key={tag.id} className="mb-1">
|
<li key={tag.id} className="mb-1">
|
||||||
<motion.div layoutId={`tag-${ tag.id }`}>
|
<motion.div layoutId={`tag-${ tag.id }`}>
|
||||||
<TagLink tag={tag} onClick={onClick}/>
|
<TagLink tag={tag} prefetch onClick={onClick}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</li>))) : [])}
|
</li>))) : [])}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -70,27 +70,18 @@ export default (({ user }: Props) => {
|
|||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ name: '広場', to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ name: '一覧', to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
{ name: '検索', to: '/posts/search' },
|
||||||
{ name: '追加', to: '/posts/new' },
|
{ name: '投稿追加', to: '/posts/new' },
|
||||||
{ name: '履歴', to: '/posts/changes' },
|
{ name: '履歴', to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ name: 'タグ', to: '/tags', subMenu: [
|
||||||
{ name: 'マスタ', to: '/tags' },
|
{ name: 'タグ一覧', to: '/tags', visible: true },
|
||||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||||
{ name: '素材', to: '/materials', subMenu: [
|
// TODO: 本実装時に消す.
|
||||||
{ name: '一覧', to: '/materials' },
|
// { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||||
// { name: '検索', to: '/materials/search' },
|
// { name: '一覧', to: '/theatres' }] },
|
||||||
{ name: '追加', to: '/materials/new' },
|
|
||||||
// { name: '履歴', to: '/materials/changes' },
|
|
||||||
{ 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' },
|
||||||
@@ -101,7 +92,7 @@ export default (({ user }: Props) => {
|
|||||||
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: 'ユーザ', to: '/users/settings', subMenu: [
|
{ name: 'ユーザ', to: '/users', 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 },
|
||||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
||||||
@@ -218,7 +209,6 @@ export default (({ user }: Props) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
key={`l-${ i }`}
|
key={`l-${ i }`}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
|
||||||
className="h-full flex items-center px-3">
|
className="h-full flex items-center px-3">
|
||||||
{item.name}
|
{item.name}
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
@@ -285,9 +275,6 @@ export default (({ user }: Props) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
key={`sp-l-${ i }-${ j }`}
|
key={`sp-l-${ i }-${ j }`}
|
||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
target={subItem.to.slice (0, 2) === '//'
|
|
||||||
? '_blank'
|
|
||||||
: undefined}
|
|
||||||
className="w-full min-h-[36px] flex items-center pl-12">
|
className="w-full min-h-[36px] flex items-center pl-12">
|
||||||
{subItem.name}
|
{subItem.name}
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
|
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
|
||||||
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
value: string
|
|
||||||
setValue: (value: string) => void }
|
|
||||||
|
|
||||||
export default (({ value, setValue }: Props) => {
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setValue (ev.target.value)
|
|
||||||
|
|
||||||
const q = ev.target.value.trim ().split (' ').at (-1)
|
|
||||||
if (!(q))
|
|
||||||
{
|
|
||||||
setSuggestions ([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
|
|
||||||
setSuggestions (data.filter (t => t.postCount > 0))
|
|
||||||
if (suggestions.length > 0)
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
|
||||||
const parts = value?.split (' ')
|
|
||||||
parts[parts.length - 1] = tag.name
|
|
||||||
setValue (parts.join (' ') + ' ')
|
|
||||||
setSuggestions ([])
|
|
||||||
setActiveIndex (-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
switch (ev.key)
|
|
||||||
{
|
|
||||||
case 'ArrowDown':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'ArrowUp':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setActiveIndex (i => Math.max (i - 1, -1))
|
|
||||||
setSuggestionsVsbl (true)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Enter':
|
|
||||||
if (activeIndex < 0)
|
|
||||||
break
|
|
||||||
ev.preventDefault ()
|
|
||||||
const selected = suggestions[activeIndex]
|
|
||||||
selected && handleTagSelect (selected)
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'Escape':
|
|
||||||
ev.preventDefault ()
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
|
||||||
{
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={whenChanged}
|
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
<TagSearchBox
|
|
||||||
suggestions={
|
|
||||||
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
|
||||||
activeIndex={activeIndex}
|
|
||||||
onSelect={handleTagSelect}/>
|
|
||||||
</div>)
|
|
||||||
}) satisfies FC<Props>
|
|
||||||
@@ -8,8 +8,6 @@ type Props = {
|
|||||||
|
|
||||||
|
|
||||||
export default (({ children, className }: Props) => (
|
export default (({ children, className }: Props) => (
|
||||||
<main className={cn ('flex-1 overflow-y-auto p-4',
|
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
|
||||||
'md:h-[calc(100dvh-88px)] md:overflow-y-auto',
|
|
||||||
className)}>
|
|
||||||
{children}
|
{children}
|
||||||
</main>)) satisfies FC<Props>
|
</main>)) satisfies FC<Props>
|
||||||
|
|||||||
@@ -1,29 +1,9 @@
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import React from 'react'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
type Props = { children: React.ReactNode }
|
||||||
|
|
||||||
type Props = { children: ReactNode }
|
|
||||||
|
|
||||||
|
|
||||||
export default (({ children }: Props) => (
|
export default ({ children }: Props) => (
|
||||||
<div
|
<div className="p-4 w-full md:w-64 md:h-full">
|
||||||
className="p-4 w-full md:w-64 md:h-full
|
|
||||||
md:h-[calc(100dvh-88px)] md:overflow-y-auto
|
|
||||||
sidebar">
|
|
||||||
<Helmet>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
.sidebar
|
|
||||||
{
|
|
||||||
direction: rtl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar > *
|
|
||||||
{
|
|
||||||
direction: ltr;
|
|
||||||
}`}
|
|
||||||
</style>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>)) satisfies FC<Props>
|
</div>)
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import { Outlet } from 'react-router-dom'
|
|
||||||
|
|
||||||
import MaterialSidebar from '@/components/MaterialSidebar'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => (
|
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
|
|
||||||
<MaterialSidebar/>
|
|
||||||
<Outlet/>
|
|
||||||
</div>)) satisfies FC
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
|
||||||
|
|
||||||
import TagLink from '@/components/TagLink'
|
|
||||||
import WikiBody from '@/components/WikiBody'
|
|
||||||
import Label from '@/components/common/Label'
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
|
||||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { toast } from '@/components/ui/use-toast'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
|
||||||
import { apiGet, apiPut } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
import type { Material, Tag } from '@/types'
|
|
||||||
|
|
||||||
type MaterialWithTag = Material & { tag: Tag }
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
const { id } = useParams ()
|
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null> (null)
|
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
|
||||||
const [loading, setLoading] = useState (false)
|
|
||||||
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
|
|
||||||
const [sending, setSending] = useState (false)
|
|
||||||
const [tag, setTag] = useState ('')
|
|
||||||
const [url, setURL] = useState ('')
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const formData = new FormData
|
|
||||||
if (tag.trim ())
|
|
||||||
formData.append ('tag', tag)
|
|
||||||
if (file)
|
|
||||||
formData.append ('file', file)
|
|
||||||
if (url.trim ())
|
|
||||||
formData.append ('url', url)
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setSending (true)
|
|
||||||
const data = await apiPut<Material> (`/materials/${ id }`, formData)
|
|
||||||
setMaterial (data)
|
|
||||||
toast ({ title: '更新成功!' })
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setSending (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(id))
|
|
||||||
return
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setLoading (true)
|
|
||||||
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
|
|
||||||
setMaterial (data)
|
|
||||||
setTag (data.tag.name)
|
|
||||||
if (data.file && data.contentType)
|
|
||||||
{
|
|
||||||
setFilePreview (data.file)
|
|
||||||
setFile (new File ([await (await fetch (data.file)).blob ()],
|
|
||||||
data.file,
|
|
||||||
{ type: data.contentType }))
|
|
||||||
}
|
|
||||||
setURL (data.url ?? '')
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainArea>
|
|
||||||
{material && (
|
|
||||||
<Helmet>
|
|
||||||
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
|
|
||||||
</Helmet>)}
|
|
||||||
|
|
||||||
{loading ? 'Loading...' : (material && (
|
|
||||||
<>
|
|
||||||
<PageTitle>
|
|
||||||
<TagLink
|
|
||||||
tag={material.tag}
|
|
||||||
withWiki={false}
|
|
||||||
withCount={false}/>
|
|
||||||
</PageTitle>
|
|
||||||
|
|
||||||
{(material.file && material.contentType) && (
|
|
||||||
(/image\/.*/.test (material.contentType) && (
|
|
||||||
<img src={material.file} alt={material.tag.name || undefined}/>))
|
|
||||||
|| (/video\/.*/.test (material.contentType) && (
|
|
||||||
<video src={material.file} controls/>))
|
|
||||||
|| (/audio\/.*/.test (material.contentType) && (
|
|
||||||
<audio src={material.file} controls/>)))}
|
|
||||||
|
|
||||||
<TabGroup>
|
|
||||||
<Tab name="Wiki">
|
|
||||||
<WikiBody
|
|
||||||
title={material.tag.name}
|
|
||||||
body={material.wikiPageBody ?? undefined}/>
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab name="編輯">
|
|
||||||
<div className="max-w-wl pt-2 space-y-4">
|
|
||||||
{/* タグ */}
|
|
||||||
<div>
|
|
||||||
<Label>タグ</Label>
|
|
||||||
<TagInput value={tag} setValue={setTag}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ファイル */}
|
|
||||||
<div>
|
|
||||||
<Label>ファイル</Label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,video/*,audio/*"
|
|
||||||
onChange={e => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
setFile (f ?? null)
|
|
||||||
setFilePreview (f ? URL.createObjectURL (f) : '')
|
|
||||||
}}/>
|
|
||||||
{(file && filePreview) && (
|
|
||||||
(/image\/.*/.test (file.type) && (
|
|
||||||
<img
|
|
||||||
src={filePreview}
|
|
||||||
alt="preview"
|
|
||||||
className="mt-2 max-h-48 rounded border"/>))
|
|
||||||
|| (/video\/.*/.test (file.type) && (
|
|
||||||
<video
|
|
||||||
src={filePreview}
|
|
||||||
controls
|
|
||||||
className="mt-2 max-h-48 rounded border"/>))
|
|
||||||
|| (/audio\/.*/.test (file.type) && (
|
|
||||||
<audio
|
|
||||||
src={filePreview}
|
|
||||||
controls
|
|
||||||
className="mt-2 max-h-48"/>))
|
|
||||||
|| (
|
|
||||||
<p className="text-red-600 dark:text-red-400">
|
|
||||||
その形式のファイルには対応していません.
|
|
||||||
</p>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参考 URL */}
|
|
||||||
<div>
|
|
||||||
<Label>参考 URL</Label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={url}
|
|
||||||
onChange={e => setURL (e.target.value)}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 送信 */}
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
|
||||||
disabled={sending}>
|
|
||||||
更新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Tab>
|
|
||||||
</TabGroup>
|
|
||||||
</>))}
|
|
||||||
</MainArea>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { Fragment, useEffect, useState } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
|
|
||||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
|
||||||
import TagLink from '@/components/TagLink'
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
|
||||||
import SectionTitle from '@/components/common/SectionTitle'
|
|
||||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
|
||||||
import { apiGet } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
import type { Material, Tag } from '@/types'
|
|
||||||
|
|
||||||
type TagWithMaterial = Omit<Tag, 'children'> & {
|
|
||||||
children: TagWithMaterial[]
|
|
||||||
material: Material | null }
|
|
||||||
|
|
||||||
|
|
||||||
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
|
|
||||||
if (!(tag.material))
|
|
||||||
return
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/materials/${ tag.material.id }`}
|
|
||||||
className="block w-40 h-40">
|
|
||||||
<div
|
|
||||||
className="w-full h-full overflow-hidden rounded-xl shadow
|
|
||||||
text-center content-center text-4xl"
|
|
||||||
style={{ fontFamily: 'Nikumaru' }}>
|
|
||||||
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
|
|
||||||
? <img src={tag.material.file || undefined}/>
|
|
||||||
: <span>照会</span>}
|
|
||||||
</div>
|
|
||||||
</PrefetchLink>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
const [loading, setLoading] = useState (false)
|
|
||||||
const [tag, setTag] = useState<TagWithMaterial | null> (null)
|
|
||||||
|
|
||||||
const location = useLocation ()
|
|
||||||
const query = new URLSearchParams (location.search)
|
|
||||||
const tagQuery = query.get ('tag') ?? ''
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(tagQuery))
|
|
||||||
{
|
|
||||||
setTag (null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setLoading (true)
|
|
||||||
setTag (
|
|
||||||
await apiGet<TagWithMaterial> (
|
|
||||||
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setLoading (false)
|
|
||||||
}
|
|
||||||
}) ()
|
|
||||||
}, [location.search])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainArea>
|
|
||||||
<Helmet>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
@font-face
|
|
||||||
{
|
|
||||||
font-family: 'Nikumaru';
|
|
||||||
src: url(${ nikumaru }) format('opentype');
|
|
||||||
}`}
|
|
||||||
</style>
|
|
||||||
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
{loading ? 'Loading...' : (
|
|
||||||
tag
|
|
||||||
? (
|
|
||||||
<>
|
|
||||||
<PageTitle>
|
|
||||||
<TagLink
|
|
||||||
tag={tag}
|
|
||||||
withWiki={false}
|
|
||||||
withCount={false}
|
|
||||||
to={tag.material
|
|
||||||
? `/materials/${ tag.material.id }`
|
|
||||||
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
|
|
||||||
</PageTitle>
|
|
||||||
{(!(tag.material) && tag.category !== 'meme') && (
|
|
||||||
<div className="-mt-2">
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
|
|
||||||
追加
|
|
||||||
</PrefetchLink>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<MaterialCard tag={tag}/>
|
|
||||||
|
|
||||||
<div className="ml-2">
|
|
||||||
{tag.children.map (c2 => (
|
|
||||||
<Fragment key={c2.id}>
|
|
||||||
<SectionTitle>
|
|
||||||
<TagLink
|
|
||||||
tag={c2}
|
|
||||||
withWiki={false}
|
|
||||||
withCount={false}
|
|
||||||
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
|
|
||||||
</SectionTitle>
|
|
||||||
{(!(c2.material) && c2.category !== 'meme') && (
|
|
||||||
<div className="-mt-4">
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
|
|
||||||
追加
|
|
||||||
</PrefetchLink>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<MaterialCard tag={c2}/>
|
|
||||||
|
|
||||||
<div className="ml-2">
|
|
||||||
{c2.children.map (c3 => (
|
|
||||||
<Fragment key={c3.id}>
|
|
||||||
<SubsectionTitle>
|
|
||||||
<TagLink
|
|
||||||
tag={c3}
|
|
||||||
withWiki={false}
|
|
||||||
withCount={false}
|
|
||||||
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
|
|
||||||
</SubsectionTitle>
|
|
||||||
{(!(c3.material) && c3.category !== 'meme') && (
|
|
||||||
<div className="-mt-2">
|
|
||||||
<PrefetchLink
|
|
||||||
to={`/materials/new?tag=${
|
|
||||||
encodeURIComponent (c3.name) }`}>
|
|
||||||
追加
|
|
||||||
</PrefetchLink>
|
|
||||||
</div>)}
|
|
||||||
|
|
||||||
<MaterialCard tag={c3}/>
|
|
||||||
</Fragment>))}
|
|
||||||
</div>
|
|
||||||
</Fragment>))}
|
|
||||||
</div>
|
|
||||||
</>)
|
|
||||||
: (
|
|
||||||
<>
|
|
||||||
<p>左のリストから照会したいタグを選択してください。</p>
|
|
||||||
<p>もしくは……</p>
|
|
||||||
<ul>
|
|
||||||
<li><PrefetchLink to="/materials/new">素材を新規追加する</PrefetchLink></li>
|
|
||||||
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
|
|
||||||
</ul>
|
|
||||||
</>))}
|
|
||||||
</MainArea>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
|
||||||
|
|
||||||
import Form from '@/components/common/Form'
|
|
||||||
import Label from '@/components/common/Label'
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { toast } from '@/components/ui/use-toast'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
|
||||||
import { apiPost } from '@/lib/api'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
const location = useLocation ()
|
|
||||||
const query = new URLSearchParams (location.search)
|
|
||||||
const tagQuery = query.get ('tag') ?? ''
|
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
|
||||||
|
|
||||||
const [file, setFile] = useState<File | null> (null)
|
|
||||||
const [filePreview, setFilePreview] = useState ('')
|
|
||||||
const [sending, setSending] = useState (false)
|
|
||||||
const [tag, setTag] = useState (tagQuery)
|
|
||||||
const [url, setURL] = useState ('')
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
const formData = new FormData
|
|
||||||
if (tag)
|
|
||||||
formData.append ('tag', tag)
|
|
||||||
if (file)
|
|
||||||
formData.append ('file', file)
|
|
||||||
if (url)
|
|
||||||
formData.append ('url', url)
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setSending (true)
|
|
||||||
await apiPost ('/materials', formData)
|
|
||||||
toast ({ title: '送信成功!' })
|
|
||||||
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setSending (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainArea>
|
|
||||||
<Helmet>
|
|
||||||
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<Form>
|
|
||||||
<PageTitle>素材追加</PageTitle>
|
|
||||||
|
|
||||||
{/* タグ */}
|
|
||||||
<div>
|
|
||||||
<Label>タグ</Label>
|
|
||||||
<TagInput value={tag} setValue={setTag}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ファイル */}
|
|
||||||
<div>
|
|
||||||
<Label>ファイル</Label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="image/*,video/*,audio/*"
|
|
||||||
onChange={e => {
|
|
||||||
const f = e.target.files?.[0]
|
|
||||||
setFile (f ?? null)
|
|
||||||
setFilePreview (f ? URL.createObjectURL (f) : '')
|
|
||||||
}}/>
|
|
||||||
{(file && filePreview) && (
|
|
||||||
(/image\/.*/.test (file.type) && (
|
|
||||||
<img
|
|
||||||
src={filePreview}
|
|
||||||
alt="preview"
|
|
||||||
className="mt-2 max-h-48 rounded border"/>))
|
|
||||||
|| (/video\/.*/.test (file.type) && (
|
|
||||||
<video
|
|
||||||
src={filePreview}
|
|
||||||
controls
|
|
||||||
className="mt-2 max-h-48 rounded border"/>))
|
|
||||||
|| (/audio\/.*/.test (file.type) && (
|
|
||||||
<audio
|
|
||||||
src={filePreview}
|
|
||||||
controls
|
|
||||||
className="mt-2 max-h-48"/>))
|
|
||||||
|| (
|
|
||||||
<p className="text-red-600 dark:text-red-400">
|
|
||||||
その形式のファイルには対応していません.
|
|
||||||
</p>))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参考 URL */}
|
|
||||||
<div>
|
|
||||||
<Label>参考 URL</Label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={url}
|
|
||||||
onChange={e => setURL (e.target.value)}
|
|
||||||
className="w-full border p-2 rounded"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 送信 */}
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
|
|
||||||
disabled={sending}>
|
|
||||||
追加
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</MainArea>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
|
|
||||||
import Label from '@/components/common/Label'
|
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
|
||||||
import TagInput from '@/components/common/TagInput'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
|
||||||
const [tagName, setTagName] = useState ('')
|
|
||||||
const [parentTagName, setParentTagName] = useState ('')
|
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
|
||||||
e.preventDefault ()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainArea>
|
|
||||||
<Helmet>
|
|
||||||
<title>素材集 | {SITE_TITLE}</title>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<div className="max-w-xl">
|
|
||||||
<PageTitle>素材集</PageTitle>
|
|
||||||
|
|
||||||
<form onSubmit={handleSearch} className="space-y-2">
|
|
||||||
{/* タグ */}
|
|
||||||
<div>
|
|
||||||
<Label>タグ</Label>
|
|
||||||
<TagInput
|
|
||||||
value={tagName}
|
|
||||||
setValue={setTagName}/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 親タグ */}
|
|
||||||
<div>
|
|
||||||
<Label>親タグ</Label>
|
|
||||||
<TagInput
|
|
||||||
value={parentTagName}
|
|
||||||
setValue={setParentTagName}/>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</MainArea>)
|
|
||||||
}) satisfies FC
|
|
||||||
@@ -93,7 +93,7 @@ export default (({ user }: Props) => {
|
|||||||
: 'bg-gray-500 hover:bg-gray-600')
|
: 'bg-gray-500 hover:bg-gray-600')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]">
|
<div className="md:flex md:flex-1">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default (() => {
|
|||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1 md:h-[calc(100dvh-88px)]" ref={containerRef}>
|
<div className="md:flex md:flex-1" ref={containerRef}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>
|
<title>
|
||||||
{tags.length
|
{tags.length
|
||||||
|
|||||||
@@ -7,22 +7,24 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import SortHeader from '@/components/SortHeader'
|
import SortHeader from '@/components/SortHeader'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
import Label from '@/components/common/Label'
|
import Label from '@/components/common/Label'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import TagInput from '@/components/common/TagInput'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
|
import { apiGet } from '@/lib/api'
|
||||||
import { fetchPosts } from '@/lib/posts'
|
import { fetchPosts } from '@/lib/posts'
|
||||||
import { postsKeys } from '@/lib/queryKeys'
|
import { postsKeys } from '@/lib/queryKeys'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import type { FetchPostsOrder,
|
import type { FetchPostsOrder,
|
||||||
FetchPostsOrderField,
|
FetchPostsOrderField,
|
||||||
FetchPostsParams } from '@/types'
|
FetchPostsParams,
|
||||||
|
Tag } from '@/types'
|
||||||
|
|
||||||
|
|
||||||
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
||||||
@@ -55,11 +57,14 @@ export default (() => {
|
|||||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||||
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
|
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
|
||||||
|
|
||||||
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
||||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||||
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
||||||
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
|
||||||
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
|
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
|
||||||
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
const [tagsStr, setTagsStr] = useState ('')
|
const [tagsStr, setTagsStr] = useState ('')
|
||||||
const [title, setTitle] = useState ('')
|
const [title, setTitle] = useState ('')
|
||||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||||
@@ -98,6 +103,58 @@ export default (() => {
|
|||||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
|
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setTagsStr (ev.target.value)
|
||||||
|
|
||||||
|
const q = ev.target.value.trim ().split (' ').at (-1)
|
||||||
|
if (!(q))
|
||||||
|
{
|
||||||
|
setSuggestions ([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
|
||||||
|
setSuggestions (data.filter (t => t.postCount > 0))
|
||||||
|
if (suggestions.length > 0)
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
switch (ev.key)
|
||||||
|
{
|
||||||
|
case 'ArrowDown':
|
||||||
|
ev.preventDefault ()
|
||||||
|
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
ev.preventDefault ()
|
||||||
|
setActiveIndex (i => Math.max (i - 1, -1))
|
||||||
|
setSuggestionsVsbl (true)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
if (activeIndex < 0)
|
||||||
|
break
|
||||||
|
ev.preventDefault ()
|
||||||
|
const selected = suggestions[activeIndex]
|
||||||
|
selected && handleTagSelect (selected)
|
||||||
|
break
|
||||||
|
|
||||||
|
case 'Escape':
|
||||||
|
ev.preventDefault ()
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||||
|
{
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
const qs = new URLSearchParams ()
|
const qs = new URLSearchParams ()
|
||||||
setIf (qs, 'tags', tagsStr)
|
setIf (qs, 'tags', tagsStr)
|
||||||
@@ -115,6 +172,15 @@ export default (() => {
|
|||||||
navigate (`${ location.pathname }?${ qs.toString () }`)
|
navigate (`${ location.pathname }?${ qs.toString () }`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||||
|
const handleTagSelect = (tag: Tag) => {
|
||||||
|
const parts = tagsStr.split (' ')
|
||||||
|
parts[parts.length - 1] = tag.name
|
||||||
|
setTagsStr (parts.join (' ') + ' ')
|
||||||
|
setSuggestions ([])
|
||||||
|
setActiveIndex (-1)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
search ()
|
search ()
|
||||||
@@ -157,11 +223,21 @@ export default (() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* タグ */}
|
{/* タグ */}
|
||||||
<div>
|
<div className="relative">
|
||||||
<Label>タグ</Label>
|
<Label>タグ</Label>
|
||||||
<TagInput
|
<input
|
||||||
|
type="text"
|
||||||
value={tagsStr}
|
value={tagsStr}
|
||||||
setValue={setTagsStr}/>
|
onChange={whenChanged}
|
||||||
|
onFocus={() => setSuggestionsVsbl (true)}
|
||||||
|
onBlur={() => setSuggestionsVsbl (false)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full border p-2 rounded"/>
|
||||||
|
<TagSearchBox
|
||||||
|
suggestions={
|
||||||
|
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
|
||||||
|
activeIndex={activeIndex}
|
||||||
|
onSelect={handleTagSelect}/>
|
||||||
<fieldset className="w-full my-2">
|
<fieldset className="w-full my-2">
|
||||||
<label>検索区分:</label>
|
<label>検索区分:</label>
|
||||||
<label className="mx-2">
|
<label className="mx-2">
|
||||||
|
|||||||
@@ -2,225 +2,85 @@ import { useEffect, useRef, useState } from 'react'
|
|||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ErrorScreen from '@/components/ErrorScreen'
|
|
||||||
import PostEmbed from '@/components/PostEmbed'
|
import PostEmbed from '@/components/PostEmbed'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
|
||||||
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
|
||||||
import { SITE_TITLE } from '@/config'
|
import { SITE_TITLE } from '@/config'
|
||||||
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api'
|
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
||||||
import { fetchPost } from '@/lib/posts'
|
import { fetchPost } from '@/lib/posts'
|
||||||
import { dateString } from '@/lib/utils'
|
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { NiconicoMetadata,
|
import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types'
|
||||||
NiconicoViewerHandle,
|
|
||||||
Post,
|
|
||||||
Theatre,
|
|
||||||
TheatreComment } from '@/types'
|
|
||||||
|
|
||||||
type TheatreInfo = {
|
type TheatreInfo = {
|
||||||
hostFlg: boolean
|
hostFlg: boolean
|
||||||
postId: number | null
|
postId: number | null
|
||||||
postStartedAt: string | null
|
postStartedAt: string | null }
|
||||||
watchingUsers: { id: number; name: string }[] }
|
|
||||||
|
|
||||||
const INITIAL_THEATRE_INFO =
|
|
||||||
{ hostFlg: false,
|
|
||||||
postId: null,
|
|
||||||
postStartedAt: null,
|
|
||||||
watchingUsers: [] as { id: number; name: string }[] } as const
|
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
export default (() => {
|
||||||
const { id } = useParams ()
|
const { id } = useParams ()
|
||||||
|
|
||||||
const commentsRef = useRef<HTMLDivElement> (null)
|
|
||||||
const embedRef = useRef<NiconicoViewerHandle> (null)
|
const embedRef = useRef<NiconicoViewerHandle> (null)
|
||||||
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
|
|
||||||
const videoLengthRef = useRef (0)
|
|
||||||
const lastCommentNoRef = useRef (0)
|
|
||||||
|
|
||||||
const [comments, setComments] = useState<TheatreComment[]> ([])
|
|
||||||
const [content, setContent] = useState ('')
|
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [sending, setSending] = useState (false)
|
|
||||||
const [status, setStatus] = useState (200)
|
|
||||||
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
||||||
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
const [theatreInfo, setTheatreInfo] =
|
||||||
|
useState<TheatreInfo> ({ hostFlg: false, postId: null, postStartedAt: null })
|
||||||
const [post, setPost] = useState<Post | null> (null)
|
const [post, setPost] = useState<Post | null> (null)
|
||||||
const [videoLength, setVideoLength] = useState (0)
|
const [videoLength, setVideoLength] = useState (9_999_999_999)
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
theatreInfoRef.current = theatreInfo
|
|
||||||
}, [theatreInfo])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
videoLengthRef.current = videoLength
|
|
||||||
}, [videoLength])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
lastCommentNoRef.current = comments[0]?.no ?? 0
|
|
||||||
}, [comments])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(id))
|
if (!(id))
|
||||||
return
|
return
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
setComments ([])
|
|
||||||
setTheatre (null)
|
|
||||||
setPost (null)
|
|
||||||
setTheatreInfo (INITIAL_THEATRE_INFO)
|
|
||||||
setVideoLength (0)
|
|
||||||
lastCommentNoRef.current = 0
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try
|
setTheatre (await apiGet<Theatre> (`/theatres/${ id }`))
|
||||||
{
|
|
||||||
const data = await apiGet<Theatre> (`/theatres/${ id }`)
|
|
||||||
if (!(cancelled))
|
|
||||||
setTheatre (data)
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
setStatus ((error as any)?.response.status ?? 200)
|
|
||||||
}
|
|
||||||
}) ()
|
}) ()
|
||||||
|
|
||||||
return () => {
|
const interval = setInterval (async () => {
|
||||||
cancelled = true
|
if (theatreInfo.hostFlg
|
||||||
}
|
&& theatreInfo.postStartedAt
|
||||||
}, [id])
|
&& ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()
|
||||||
|
> videoLength))
|
||||||
|
setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null })
|
||||||
|
else
|
||||||
|
setTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
|
||||||
|
}, 1_000)
|
||||||
|
|
||||||
|
return () => clearInterval (interval)
|
||||||
|
}, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(id))
|
if (!(theatreInfo.hostFlg) || loading)
|
||||||
return
|
return
|
||||||
|
|
||||||
let cancelled = false
|
if (theatreInfo.postId == null)
|
||||||
let running = false
|
|
||||||
|
|
||||||
const tick = async () => {
|
|
||||||
if (running)
|
|
||||||
return
|
|
||||||
|
|
||||||
running = true
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
const newComments = await apiGet<TheatreComment[]> (
|
|
||||||
`/theatres/${ id }/comments`,
|
|
||||||
{ params: { no_gt: lastCommentNoRef.current } })
|
|
||||||
|
|
||||||
if (!(cancelled) && newComments.length > 0)
|
|
||||||
{
|
|
||||||
lastCommentNoRef.current = newComments[newComments.length - 1].no
|
|
||||||
setComments (prev => [...newComments, ...prev])
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentInfo = theatreInfoRef.current
|
|
||||||
const ended =
|
|
||||||
currentInfo.hostFlg
|
|
||||||
&& currentInfo.postStartedAt
|
|
||||||
&& ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ())
|
|
||||||
> videoLengthRef.current + 3_000)
|
|
||||||
|
|
||||||
if (ended)
|
|
||||||
{
|
|
||||||
if (!(cancelled))
|
|
||||||
setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null }))
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
|
|
||||||
if (!(cancelled))
|
|
||||||
setTheatreInfo (nextInfo)
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
console.error (error)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
running = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tick ()
|
|
||||||
const interval = setInterval (() => tick (), 1_500)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
clearInterval (interval)
|
|
||||||
}
|
|
||||||
}, [id])
|
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null)
|
|
||||||
return
|
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setLoading (true)
|
setLoading (true)
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
console.error (error)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
if (!(cancelled))
|
|
||||||
setLoading (false)
|
setLoading (false)
|
||||||
}
|
|
||||||
}) ()
|
}) ()
|
||||||
|
return
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
}
|
||||||
}, [id, theatreInfo.hostFlg, theatreInfo.postId])
|
}, [id, loading, theatreInfo.hostFlg, theatreInfo.postId])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setVideoLength (0)
|
|
||||||
|
|
||||||
if (theatreInfo.postId == null)
|
if (theatreInfo.postId == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
let cancelled = false
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try
|
setPost (await fetchPost (String (theatreInfo.postId)))
|
||||||
{
|
|
||||||
const nextPost = await fetchPost (String (theatreInfo.postId))
|
|
||||||
if (!(cancelled))
|
|
||||||
setPost (nextPost)
|
|
||||||
}
|
|
||||||
catch (error)
|
|
||||||
{
|
|
||||||
console.error (error)
|
|
||||||
}
|
|
||||||
}) ()
|
}) ()
|
||||||
|
}, [theatreInfo.postId, theatreInfo.postStartedAt])
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [theatreInfo.postId])
|
|
||||||
|
|
||||||
const syncPlayback = (meta: NiconicoMetadata) => {
|
const syncPlayback = (meta: NiconicoMetadata) => {
|
||||||
if (!(theatreInfo.postStartedAt))
|
if (!(theatreInfo.postStartedAt))
|
||||||
return
|
return
|
||||||
|
|
||||||
const targetTime = Math.min (
|
const targetTime =
|
||||||
Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()),
|
((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ())
|
||||||
videoLength)
|
|
||||||
|
|
||||||
const drift = Math.abs (meta.currentTime - targetTime)
|
const drift = Math.abs (meta.currentTime - targetTime)
|
||||||
|
|
||||||
@@ -228,11 +88,8 @@ export default (() => {
|
|||||||
embedRef.current?.seek (targetTime)
|
embedRef.current?.seek (targetTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status >= 400)
|
|
||||||
return <ErrorScreen status={status}/>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1">
|
<MainArea>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
{theatre && (
|
{theatre && (
|
||||||
<title>
|
<title>
|
||||||
@@ -242,100 +99,16 @@ export default (() => {
|
|||||||
</title>)}
|
</title>)}
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
{post && (
|
||||||
{post && <TagDetailSidebar post={post}/>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MainArea>
|
|
||||||
{post ? (
|
|
||||||
<>
|
|
||||||
<PostEmbed
|
<PostEmbed
|
||||||
key={post.id}
|
|
||||||
ref={embedRef}
|
ref={embedRef}
|
||||||
post={post}
|
post={post}
|
||||||
onLoadComplete={info => {
|
onLoadComplete={info => {
|
||||||
embedRef.current?.play ()
|
embedRef.current?.play ()
|
||||||
setVideoLength (info.lengthInSeconds * 1_000)
|
setVideoLength (info.lengthInSeconds * 1_000)
|
||||||
}}
|
}}
|
||||||
onMetadataChange={syncPlayback}/>
|
onMetadataChange={meta => {
|
||||||
<div className="m-2">
|
syncPlayback (meta)
|
||||||
<>再生中:</>
|
}}/>)}
|
||||||
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold">
|
</MainArea>)
|
||||||
{post.title || post.url}
|
|
||||||
</PrefetchLink>
|
|
||||||
</div>
|
|
||||||
</>) : 'Loading...'}
|
|
||||||
</MainArea>
|
|
||||||
|
|
||||||
<SidebarComponent>
|
|
||||||
<form
|
|
||||||
className="w-auto h-auto border border-black dark:border-white rounded mx-2"
|
|
||||||
onSubmit={async e => {
|
|
||||||
e.preventDefault ()
|
|
||||||
|
|
||||||
if (!(content))
|
|
||||||
return
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
setSending (true)
|
|
||||||
await apiPost (`/theatres/${ id }/comments`, { content })
|
|
||||||
setContent ('')
|
|
||||||
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setSending (false)
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<input
|
|
||||||
className="w-full p-2 border rounded"
|
|
||||||
type="text"
|
|
||||||
placeholder="ここにコメントを入力"
|
|
||||||
value={content}
|
|
||||||
onChange={e => setContent (e.target.value)}
|
|
||||||
disabled={sending}/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={commentsRef}
|
|
||||||
className="overflow-x-hidden overflow-y-scroll text-wrap w-full
|
|
||||||
h-[32vh] md:h-[64vh] border rounded">
|
|
||||||
{comments.map (comment => (
|
|
||||||
<div key={comment.no} className="p-2">
|
|
||||||
<div className="w-full">
|
|
||||||
{comment.content}
|
|
||||||
</div>
|
|
||||||
<div className="w-full text-sm text-right">
|
|
||||||
by {comment.user
|
|
||||||
? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`)
|
|
||||||
: '運営'}
|
|
||||||
</div>
|
|
||||||
<div className="w-full text-sm text-right">
|
|
||||||
{dateString (comment.createdAt)}
|
|
||||||
</div>
|
|
||||||
</div>))}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="w-auto h-auto border border-black dark:border-white rounded mx-2 mt-4">
|
|
||||||
<div className="p-2">
|
|
||||||
現在の同接数:{theatreInfo.watchingUsers.length}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-hidden overflow-y-scroll text-wrap w-full h-32
|
|
||||||
border rounded">
|
|
||||||
<ul className="list-inside list-disc">
|
|
||||||
{theatreInfo.watchingUsers.map (user => (
|
|
||||||
<li key={user.id} className="px-4 py-1 text-sm">
|
|
||||||
{user.name || `名もなきニジラー(#${ user.id })`}
|
|
||||||
</li>))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarComponent>
|
|
||||||
|
|
||||||
<div className="md:hidden">
|
|
||||||
{post && <TagDetailSidebar post={post} sp/>}
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}) satisfies FC
|
}) satisfies FC
|
||||||
|
|||||||
+2
-22
@@ -49,22 +49,10 @@ export type FetchTagsParams = {
|
|||||||
limit: number
|
limit: number
|
||||||
order: FetchTagsOrder }
|
order: FetchTagsOrder }
|
||||||
|
|
||||||
export type Material = {
|
|
||||||
id: number
|
|
||||||
tag: Tag
|
|
||||||
file: string | null
|
|
||||||
url: string | null
|
|
||||||
wikiPageBody?: string | null
|
|
||||||
contentType: string | null
|
|
||||||
createdAt: string
|
|
||||||
createdByUser: { id: number; name: string }
|
|
||||||
updatedAt: string
|
|
||||||
updatedByUser: { id: number; name: string } }
|
|
||||||
|
|
||||||
export type Menu = MenuItem[]
|
export type Menu = MenuItem[]
|
||||||
|
|
||||||
export type MenuItem = {
|
export type MenuItem = {
|
||||||
name: ReactNode
|
name: string
|
||||||
to: string
|
to: string
|
||||||
base?: string
|
base?: string
|
||||||
subMenu: SubMenuItem[] }
|
subMenu: SubMenuItem[] }
|
||||||
@@ -129,7 +117,7 @@ export type PostTagChange = {
|
|||||||
export type SubMenuItem =
|
export type SubMenuItem =
|
||||||
| { component: ReactNode
|
| { component: ReactNode
|
||||||
visible: boolean }
|
visible: boolean }
|
||||||
| { name: ReactNode
|
| { name: string
|
||||||
to: string
|
to: string
|
||||||
visible?: boolean }
|
visible?: boolean }
|
||||||
|
|
||||||
@@ -141,7 +129,6 @@ export type Tag = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
hasWiki: boolean
|
hasWiki: boolean
|
||||||
materialId: number
|
|
||||||
children?: Tag[]
|
children?: Tag[]
|
||||||
matchedAlias?: string | null }
|
matchedAlias?: string | null }
|
||||||
|
|
||||||
@@ -154,13 +141,6 @@ export type Theatre = {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string }
|
updatedAt: string }
|
||||||
|
|
||||||
export type TheatreComment = {
|
|
||||||
theatreId: number,
|
|
||||||
no: number,
|
|
||||||
user: { id: number, name: string } | null
|
|
||||||
content: string
|
|
||||||
createdAt: string }
|
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: number
|
id: number
|
||||||
name: string | null
|
name: string | null
|
||||||
|
|||||||
Reference in New Issue
Block a user