Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 351b8348c9 | |||
| 2ea08ef4dd | |||
| 58429c5e8b | |||
| 96307af509 | |||
| f3a2b08359 | |||
| 86de85ad91 | |||
| 5c7580d571 | |||
| f3d9e88ea2 | |||
| 48f823a7c8 | |||
| bd11e37fd3 | |||
| e72ec608f4 | |||
| a3914fb22a | |||
| c36b2c8a1b | |||
| e021423904 | |||
| 7b15cb2c5a | |||
| 2adff3966a | |||
| ee93ff8ea0 |
+2
-2
@@ -50,8 +50,6 @@ group :development, :test do
|
||||
gem 'factory_bot_rails'
|
||||
end
|
||||
|
||||
|
||||
|
||||
gem "mysql2", "~> 0.5.6"
|
||||
|
||||
gem "image_processing", "~> 1.14"
|
||||
@@ -69,3 +67,5 @@ gem 'whenever', require: false
|
||||
gem 'discard'
|
||||
|
||||
gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
|
||||
|
||||
gem 'aws-sdk-s3', require: false
|
||||
|
||||
@@ -73,6 +73,25 @@ GEM
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
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)
|
||||
bcrypt_pbkdf (1.1.1)
|
||||
bcrypt_pbkdf (1.1.1-arm64-darwin)
|
||||
@@ -157,6 +176,7 @@ GEM
|
||||
pp (>= 0.6.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.12.0)
|
||||
jwt (2.10.1)
|
||||
base64
|
||||
@@ -441,6 +461,7 @@ PLATFORMS
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
aws-sdk-s3
|
||||
bootsnap
|
||||
brakeman
|
||||
diff-lcs
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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
|
||||
@@ -30,14 +30,21 @@ class NicoTagsController < ApplicationController
|
||||
id = params[:id].to_i
|
||||
|
||||
tag = Tag.find(id)
|
||||
return head :bad_request if tag.category != 'nico'
|
||||
return head :bad_request unless tag.nico?
|
||||
|
||||
linked_tag_names = params[:tags].to_s.split(' ')
|
||||
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false)
|
||||
return head :bad_request if linked_tags.any? { |t| t.category == 'nico' }
|
||||
linked_tag_names = params[:tags].to_s.split
|
||||
linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false,
|
||||
with_no_deerjikist: false)
|
||||
return head :bad_request if linked_tags.any? { |t| t.nico? }
|
||||
|
||||
tag.linked_tags = linked_tags
|
||||
tag.save!
|
||||
ApplicationRecord.transaction do
|
||||
TagVersioning.record_tag_snapshots!(linked_tags, created_by_user: current_user)
|
||||
|
||||
tag.linked_tags = linked_tags
|
||||
tag.save!
|
||||
|
||||
NicoTagVersionRecorder.record!(tag:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
|
||||
render json: tag.linked_tags.map { |t| TagRepr.base(t) }, status: :ok
|
||||
end
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
class PostVersionsController < ApplicationController
|
||||
def index
|
||||
post_id = params[:post].presence
|
||||
tag_id = params[:tag].presence
|
||||
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_name =
|
||||
if tag_id
|
||||
TagName.joins(:tag).find_by(tag: { id: tag_id })
|
||||
end
|
||||
return render json: { versions: [], count: 0 } if tag_id && tag_name.blank?
|
||||
|
||||
q = PostVersion.joins(<<~SQL.squish)
|
||||
LEFT JOIN
|
||||
post_versions prev
|
||||
ON
|
||||
prev.post_id = post_versions.post_id
|
||||
AND prev.version_no = post_versions.version_no - 1
|
||||
SQL
|
||||
.select('post_versions.*', 'prev.title AS prev_title', 'prev.url AS prev_url',
|
||||
'prev.thumbnail_base AS prev_thumbnail_base', 'prev.tags AS prev_tags',
|
||||
'prev.original_created_from AS prev_original_created_from',
|
||||
'prev.original_created_before AS prev_original_created_before')
|
||||
q = q.where('post_versions.post_id = ?', post_id) if post_id
|
||||
if tag_name
|
||||
escaped = ActiveRecord::Base.sanitize_sql_like(tag_name.name)
|
||||
q = q.where(("CONCAT(' ', post_versions.tags, ' ') LIKE :kw " +
|
||||
"OR CONCAT(' ', prev.tags, ' ') LIKE :kw"),
|
||||
kw: "% #{ escaped } %")
|
||||
end
|
||||
|
||||
count = q.except(:select, :order, :limit, :offset).count
|
||||
|
||||
versions = q.order(Arel.sql('post_versions.created_at DESC, post_versions.id DESC'))
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialise_versions rows
|
||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||
|
||||
rows.map do |row|
|
||||
cur_tags = split_tags(row.tags)
|
||||
prev_tags = split_tags(row.attributes['prev_tags'])
|
||||
|
||||
{
|
||||
post_id: row.post_id,
|
||||
version_no: row.version_no,
|
||||
event_type: row.event_type,
|
||||
title: {
|
||||
current: row.title,
|
||||
prev: row.attributes['prev_title']
|
||||
},
|
||||
url: {
|
||||
current: row.url,
|
||||
prev: row.attributes['prev_url']
|
||||
},
|
||||
thumbnail: {
|
||||
current: nil,
|
||||
prev: nil
|
||||
},
|
||||
thumbnail_base: {
|
||||
current: row.thumbnail_base,
|
||||
prev: row.attributes['prev_thumbnail_base']
|
||||
},
|
||||
tags: build_version_tags(cur_tags, prev_tags),
|
||||
original_created_from: {
|
||||
current: row.original_created_from&.iso8601,
|
||||
prev: row.attributes['prev_original_created_from']&.iso8601
|
||||
},
|
||||
original_created_before: {
|
||||
current: row.original_created_before&.iso8601,
|
||||
prev: row.attributes['prev_original_created_before']&.iso8601
|
||||
},
|
||||
created_at: row.created_at.iso8601,
|
||||
created_by_user:
|
||||
if row.created_by_user_id
|
||||
{
|
||||
id: row.created_by_user_id,
|
||||
name: users_by_id[row.created_by_user_id]
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def build_version_tags(cur_tags, prev_tags)
|
||||
(cur_tags | prev_tags).map do |name|
|
||||
type =
|
||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
||||
'context'
|
||||
elsif cur_tags.include?(name)
|
||||
'added'
|
||||
else
|
||||
'removed'
|
||||
end
|
||||
|
||||
{
|
||||
name:,
|
||||
type:
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def split_tags(tags)
|
||||
tags.to_s.split(/\s+/).reject(&:blank?)
|
||||
end
|
||||
end
|
||||
@@ -44,7 +44,7 @@ class PostsController < ApplicationController
|
||||
filtered_posts
|
||||
.joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id")
|
||||
.reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all"))
|
||||
.preload(tags: { tag_name: :wiki_page })
|
||||
.preload(tags: [:materials, { tag_name: :wiki_page }])
|
||||
.with_attached_thumbnail
|
||||
|
||||
q = q.where('posts.url LIKE ?', "%#{ url }%") if url
|
||||
@@ -95,7 +95,7 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def random
|
||||
post = filtered_posts.preload(tags: { tag_name: :wiki_page })
|
||||
post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }])
|
||||
.order('RAND()')
|
||||
.first
|
||||
return head :not_found unless post
|
||||
@@ -104,7 +104,7 @@ class PostsController < ApplicationController
|
||||
end
|
||||
|
||||
def show
|
||||
post = Post.includes(tags: { tag_name: :wiki_page }).find_by(id: params[:id])
|
||||
post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id])
|
||||
return head :not_found unless post
|
||||
|
||||
render json: PostRepr.base(post, current_user)
|
||||
@@ -127,17 +127,22 @@ class PostsController < ApplicationController
|
||||
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
|
||||
original_created_from:, original_created_before:)
|
||||
post.thumbnail.attach(thumbnail)
|
||||
if post.save
|
||||
post.resized_thumbnail!
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
post.save!
|
||||
tags = Tag.normalise_tags(tag_names)
|
||||
TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user)
|
||||
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
sync_post_tags!(post, tags)
|
||||
|
||||
post.reload
|
||||
render json: PostRepr.base(post), status: :created
|
||||
else
|
||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||
post.resized_thumbnail!
|
||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
||||
end
|
||||
|
||||
post.reload
|
||||
render json: PostRepr.base(post), status: :created
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
head :bad_request
|
||||
end
|
||||
@@ -166,19 +171,27 @@ class PostsController < ApplicationController
|
||||
original_created_before = params[:original_created_before]
|
||||
|
||||
post = Post.find(params[:id].to_i)
|
||||
if post.update(title:, original_created_from:, original_created_before:)
|
||||
tags = post.tags.where(category: 'nico').to_a +
|
||||
Tag.normalise_tags(tag_names, with_tagme: false)
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user)
|
||||
|
||||
post.update!(title:, original_created_from:, original_created_before:)
|
||||
|
||||
normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false)
|
||||
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
|
||||
|
||||
tags = post.tags.nico.to_a + normalised_tags
|
||||
tags = Tag.expand_parent_tags(tags)
|
||||
sync_post_tags!(post, tags)
|
||||
|
||||
post.reload
|
||||
json = post.as_json
|
||||
json['tags'] = build_tag_tree_for(post.tags)
|
||||
render json:, status: :ok
|
||||
else
|
||||
render json: post.errors, status: :unprocessable_entity
|
||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
|
||||
post.reload
|
||||
json = post.as_json
|
||||
json['tags'] = build_tag_tree_for(post.tags)
|
||||
render json:, status: :ok
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
render json: post.errors, status: :unprocessable_entity
|
||||
rescue Tag::NicoTagNormalisationError
|
||||
head :bad_request
|
||||
end
|
||||
@@ -198,7 +211,7 @@ class PostsController < ApplicationController
|
||||
pts = pts.where(post_id: id) if id.present?
|
||||
pts = pts.where(tag_id:) if tag_id.present?
|
||||
pts = pts.includes(:post, :created_user, :deleted_user,
|
||||
tag: { tag_name: :wiki_page })
|
||||
tag: [:materials, { tag_name: :wiki_page }])
|
||||
|
||||
events = []
|
||||
pts.each do |pt|
|
||||
|
||||
@@ -7,7 +7,16 @@ class TagChildrenController < ApplicationController
|
||||
child_id = params[:child_id]
|
||||
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||
|
||||
Tag.find(parent_id).children << Tag.find(child_id) rescue nil
|
||||
parent = Tag.find(parent_id)
|
||||
child = Tag.find(child_id)
|
||||
return head :bad_request if parent.nico? || child.nico?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||
|
||||
TagImplication.find_or_create_by!(parent_tag: parent, tag: child)
|
||||
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
|
||||
head :no_content
|
||||
end
|
||||
@@ -20,7 +29,16 @@ class TagChildrenController < ApplicationController
|
||||
child_id = params[:child_id]
|
||||
return head :bad_request if parent_id.blank? || child_id.blank?
|
||||
|
||||
Tag.find(parent_id).children.delete(Tag.find(child_id)) rescue nil
|
||||
parent = Tag.find(parent_id)
|
||||
child = Tag.find(child_id)
|
||||
return head :bad_request if parent.nico? || child.nico?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
TagVersioning.ensure_snapshot!(child, created_by_user: current_user)
|
||||
|
||||
TagImplication.find_by(parent_tag: parent, tag: child)&.destroy!
|
||||
TagVersionRecorder.record!(tag: child, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
@@ -33,11 +33,11 @@ class TagsController < ApplicationController
|
||||
else
|
||||
Tag.joins(:tag_name)
|
||||
end
|
||||
.includes(:tag_name, tag_name: :wiki_page)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
q = q.where(posts: { id: post_id }) if post_id.present?
|
||||
|
||||
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
|
||||
q = q.where(category: category) if category
|
||||
q = q.where(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[1]) if post_count_between[1]
|
||||
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
|
||||
@@ -69,6 +69,44 @@ class TagsController < ApplicationController
|
||||
render json: { tags: TagRepr.base(tags), count: q.size }
|
||||
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
|
||||
q = params[:q].to_s.strip.sub(/\Anot:/i, '')
|
||||
|
||||
@@ -90,7 +128,7 @@ class TagsController < ApplicationController
|
||||
end
|
||||
|
||||
base = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, tag_name: :wiki_page)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
base = base.where('tags.post_count > 0') if present_only
|
||||
|
||||
canonical_hit =
|
||||
@@ -115,7 +153,7 @@ class TagsController < ApplicationController
|
||||
|
||||
def show
|
||||
tag = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, tag_name: :wiki_page)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.find_by(id: params[:id])
|
||||
if tag
|
||||
render json: TagRepr.base(tag)
|
||||
@@ -129,7 +167,7 @@ class TagsController < ApplicationController
|
||||
return head :bad_request if name.blank?
|
||||
|
||||
tag = Tag.joins(:tag_name)
|
||||
.includes(:tag_name, tag_name: :wiki_page)
|
||||
.includes(:tag_name, :materials, tag_name: :wiki_page)
|
||||
.find_by(tag_names: { name: })
|
||||
if tag
|
||||
render json: TagRepr.base(tag)
|
||||
@@ -159,6 +197,18 @@ class TagsController < ApplicationController
|
||||
render json: DeerjikistRepr.many(tag.deerjikists)
|
||||
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
|
||||
return head :unauthorized unless current_user
|
||||
return head :forbidden unless current_user.gte_member?
|
||||
@@ -168,14 +218,44 @@ class TagsController < ApplicationController
|
||||
|
||||
tag = Tag.find(params[:id])
|
||||
|
||||
if name.present?
|
||||
tag.tag_name.update!(name:)
|
||||
if category.present? && tag.nico? != (category == 'nico')
|
||||
return render json: { error: 'ニコタグのカテゴリ変更はできません.' },
|
||||
status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if category.present?
|
||||
tag.update!(category:)
|
||||
ApplicationRecord.transaction do
|
||||
TagVersioning.ensure_snapshot!(tag, created_by_user: current_user)
|
||||
|
||||
tag.tag_name.update!(name:) if name.present?
|
||||
tag.update!(category:) if category.present?
|
||||
|
||||
record_tag_version!(tag, event_type: :update, created_by_user: current_user)
|
||||
end
|
||||
|
||||
render json: TagRepr.base(tag)
|
||||
render json: TagRepr.base(tag.reload)
|
||||
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
|
||||
|
||||
def record_tag_version!(tag, event_type:, created_by_user:)
|
||||
if tag.nico?
|
||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||
else
|
||||
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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,7 +31,9 @@ class TheatresController < ApplicationController
|
||||
post_started_at = theatre.current_post_started_at
|
||||
end
|
||||
|
||||
render json: { host_flg:, post_id:, post_started_at: }
|
||||
render json: {
|
||||
host_flg:, post_id:, post_started_at:,
|
||||
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
|
||||
end
|
||||
|
||||
def next_post
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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
|
||||
@@ -1,7 +1,11 @@
|
||||
module MyDiscard
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included { include Discard::Model }
|
||||
included do
|
||||
include Discard::Model
|
||||
|
||||
default_scope -> { kept }
|
||||
end
|
||||
|
||||
class_methods do
|
||||
def find_undiscard_or_create_by! attrs, &block
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class NicoTagVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :tag
|
||||
|
||||
validates :name, presence: true
|
||||
end
|
||||
@@ -11,6 +11,7 @@ class Post < ApplicationRecord
|
||||
|
||||
has_many :user_post_views, dependent: :delete_all
|
||||
has_many :post_similarities, dependent: :delete_all
|
||||
has_many :post_versions
|
||||
|
||||
has_one_attached :thumbnail
|
||||
|
||||
@@ -30,6 +31,8 @@ class Post < ApplicationRecord
|
||||
super(options).merge(thumbnail: nil)
|
||||
end
|
||||
|
||||
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
|
||||
def related limit: nil
|
||||
ids = post_similarities.order(cos: :desc)
|
||||
ids = ids.limit(limit) if limit
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
class PostVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :post
|
||||
belongs_to :parent, class_name: 'Post', optional: true
|
||||
|
||||
validates :url, presence: true
|
||||
|
||||
validate :validate_original_created_range
|
||||
|
||||
private
|
||||
|
||||
def validate_original_created_range
|
||||
f = original_created_from
|
||||
b = original_created_before
|
||||
return if f.blank? || b.blank?
|
||||
|
||||
if f >= b
|
||||
errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.'
|
||||
end
|
||||
end
|
||||
end
|
||||
+42
-24
@@ -1,3 +1,6 @@
|
||||
require 'set'
|
||||
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
@@ -5,8 +8,6 @@ class Tag < ApplicationRecord
|
||||
;
|
||||
end
|
||||
|
||||
default_scope -> { kept }
|
||||
|
||||
has_many :post_tags, inverse_of: :tag
|
||||
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
|
||||
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
|
||||
@@ -31,6 +32,10 @@ class Tag < ApplicationRecord
|
||||
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
|
||||
|
||||
has_many :deerjikists, dependent: :delete_all
|
||||
has_many :materials
|
||||
|
||||
has_many :tag_versions
|
||||
has_many :nico_tag_versions
|
||||
|
||||
belongs_to :tag_name
|
||||
delegate :wiki_page, to: :tag_name
|
||||
@@ -72,27 +77,17 @@ class Tag < ApplicationRecord
|
||||
|
||||
def has_wiki = wiki_page.present?
|
||||
|
||||
def self.tagme
|
||||
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
|
||||
end
|
||||
def material_id = materials.first&.id
|
||||
|
||||
def self.bot
|
||||
@bot ||= find_or_create_by_tag_name!('bot操作', category: :meta)
|
||||
end
|
||||
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
|
||||
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
|
||||
def self.no_deerjikist = find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
|
||||
def self.video = find_or_create_by_tag_name!('動画', category: :meta)
|
||||
def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta)
|
||||
|
||||
def self.no_deerjikist
|
||||
@no_deerjikist ||= find_or_create_by_tag_name!('ニジラー情報不詳', category: :meta)
|
||||
end
|
||||
|
||||
def self.video
|
||||
@video ||= find_or_create_by_tag_name!('動画', category: :meta)
|
||||
end
|
||||
|
||||
def self.niconico
|
||||
@niconico ||= find_or_create_by_tag_name!('ニコニコ', category: :meta)
|
||||
end
|
||||
|
||||
def self.normalise_tags tag_names, with_tagme: true, deny_nico: true
|
||||
def self.normalise_tags tag_names, with_tagme: true,
|
||||
with_no_deerjikist: true,
|
||||
deny_nico: true
|
||||
if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') }
|
||||
raise NicoTagNormalisationError
|
||||
end
|
||||
@@ -106,7 +101,7 @@ class Tag < ApplicationRecord
|
||||
end
|
||||
|
||||
tags << Tag.tagme if with_tagme && tags.size < 10 && tags.none?(Tag.tagme)
|
||||
tags << Tag.no_deerjikist if tags.all? { |t| !(t.deerjikist?) }
|
||||
tags << Tag.no_deerjikist if with_no_deerjikist && tags.all? { |t| !(t.deerjikist?) }
|
||||
tags.uniq(&:id)
|
||||
end
|
||||
|
||||
@@ -144,18 +139,25 @@ class Tag < ApplicationRecord
|
||||
retry
|
||||
end
|
||||
|
||||
def self.merge_tags! target_tag, source_tags
|
||||
def self.merge_tags! target_tag, source_tags, created_by_user: nil
|
||||
target_tag => Tag
|
||||
|
||||
affected_post_ids = Set.new
|
||||
|
||||
Tag.transaction do
|
||||
TagVersioning.ensure_snapshot!(target_tag, created_by_user:)
|
||||
|
||||
Array(source_tags).compact.uniq.each do |source_tag|
|
||||
source_tag => Tag
|
||||
|
||||
next if source_tag == target_tag
|
||||
|
||||
TagVersioning.ensure_snapshot!(source_tag, created_by_user:)
|
||||
|
||||
source_tag.post_tags.kept.find_each do |source_pt|
|
||||
post_id = source_pt.post_id
|
||||
source_pt.discard_by!(nil)
|
||||
affected_post_ids << post_id
|
||||
source_pt.discard_by!(created_by_user)
|
||||
unless PostTag.kept.exists?(post_id:, tag: target_tag)
|
||||
PostTag.create!(post_id:, tag: target_tag)
|
||||
end
|
||||
@@ -167,6 +169,7 @@ class Tag < ApplicationRecord
|
||||
raise ActiveRecord::RecordInvalid.new(source_tag_name)
|
||||
end
|
||||
|
||||
TagVersioning.record!(source_tag, event_type: :discard, created_by_user:)
|
||||
source_tag.discard!
|
||||
|
||||
if source_tag.nico?
|
||||
@@ -175,6 +178,13 @@ class Tag < ApplicationRecord
|
||||
source_tag_name.update_columns(canonical_id: target_tag.tag_name_id,
|
||||
updated_at: Time.current)
|
||||
end
|
||||
|
||||
TagVersioning.record!(target_tag, event_type: :update, created_by_user:)
|
||||
end
|
||||
|
||||
Post.where(id: affected_post_ids.to_a).find_each do |post|
|
||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user:)
|
||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user:)
|
||||
end
|
||||
|
||||
# 投稿件数を再集計
|
||||
@@ -184,6 +194,14 @@ class Tag < ApplicationRecord
|
||||
target_tag.reload
|
||||
end
|
||||
|
||||
def snapshot_aliases = tag_name.aliases.kept.order(:name).pluck(:name)
|
||||
|
||||
def snapshot_parent_tag_ids = parents.order(:id).pluck(:id)
|
||||
|
||||
def snapshot_linked_tag_names
|
||||
linked_tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def nico_tag_name_must_start_with_nico
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
class TagName < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
default_scope -> { kept }
|
||||
|
||||
has_one :tag
|
||||
has_one :wiki_page
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class TagVersion < ApplicationRecord
|
||||
include VersionRecord
|
||||
|
||||
belongs_to :tag
|
||||
|
||||
enum :category, { deerjikist: 'deerjikist',
|
||||
meme: 'meme',
|
||||
character: 'character',
|
||||
general: 'general',
|
||||
material: 'material',
|
||||
meta: 'meta' }, validate: true
|
||||
|
||||
validates :name, presence: true
|
||||
validates :category, presence: true
|
||||
end
|
||||
@@ -1,8 +1,8 @@
|
||||
class TheatreComment < ApplicationRecord
|
||||
include MyDiscard
|
||||
include Discard::Model
|
||||
|
||||
self.primary_key = :theatre_id, :no
|
||||
|
||||
belongs_to :theatre
|
||||
belongs_to :user
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
module VersionRecord
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def readonly? = persisted?
|
||||
|
||||
included do
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
|
||||
enum :event_type, { create: 'create',
|
||||
update: 'update',
|
||||
discard: 'discard',
|
||||
restore: 'restore' }, prefix: true, validate: true
|
||||
|
||||
validates :version_no, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :event_type, presence: true
|
||||
|
||||
scope :chronological, -> { order(:version_no, :id) }
|
||||
end
|
||||
end
|
||||
@@ -4,8 +4,6 @@ require 'set'
|
||||
class WikiPage < ApplicationRecord
|
||||
include MyDiscard
|
||||
|
||||
default_scope -> { kept }
|
||||
|
||||
has_many :wiki_revisions, dependent: :destroy
|
||||
belongs_to :created_user, class_name: 'User'
|
||||
belongs_to :updated_user, class_name: 'User'
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 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
|
||||
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
|
||||
methods: [:name, :has_wiki] }.freeze
|
||||
methods: [:name, :has_wiki, :material_id] }.freeze
|
||||
|
||||
module_function
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class NicoTagVersionRecorder < VersionRecorder
|
||||
def self.record! tag:, event_type:, created_by_user:
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize tag:, event_type:, created_by_user:
|
||||
super(record: tag, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = NicoTagVersion
|
||||
def version_association = :nico_tag_versions
|
||||
def record_key = :tag
|
||||
|
||||
def snapshot_attributes
|
||||
{ name: @record.name, linked_tags: @record.snapshot_linked_tag_names.join(' ') }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
class PostVersionRecorder < VersionRecorder
|
||||
def self.record! post:, event_type:, created_by_user:
|
||||
new(post:, event_type:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize post:, event_type:, created_by_user:
|
||||
super(record: post, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
def self.ensure_snapshot! post, created_by_user:
|
||||
return if post.post_versions.exists?
|
||||
|
||||
record!(post:, event_type: :create, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = PostVersion
|
||||
def version_association = :post_versions
|
||||
def record_key = :post
|
||||
|
||||
def snapshot_attributes
|
||||
{ title: @record.title,
|
||||
url: @record.url,
|
||||
thumbnail_base: @record.thumbnail_base,
|
||||
tags: @record.snapshot_tag_names.join(' '),
|
||||
parent_id: @record.parent_id,
|
||||
original_created_from: @record.original_created_from,
|
||||
original_created_before: @record.original_created_before }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
class TagVersionRecorder < VersionRecorder
|
||||
def self.record! tag:, event_type:, created_by_user:
|
||||
new(tag:, event_type:, created_by_user:).record!
|
||||
end
|
||||
|
||||
def initialize tag:, event_type:, created_by_user:
|
||||
super(record: tag, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def version_class = TagVersion
|
||||
def version_association = :tag_versions
|
||||
def record_key = :tag
|
||||
|
||||
def snapshot_attributes
|
||||
{ name: @record.name,
|
||||
category: @record.category,
|
||||
aliases: @record.snapshot_aliases.join(' '),
|
||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
class TagVersioning
|
||||
def self.record! tag, event_type:, created_by_user:
|
||||
if tag.nico?
|
||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||
else
|
||||
TagVersionRecorder.record!(tag:, event_type:, created_by_user:)
|
||||
end
|
||||
end
|
||||
|
||||
def self.ensure_snapshot! tag, created_by_user:
|
||||
if tag.nico?
|
||||
return if tag.nico_tag_versions.exists?
|
||||
|
||||
NicoTagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
||||
else
|
||||
return if tag.tag_versions.exists?
|
||||
|
||||
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:)
|
||||
end
|
||||
end
|
||||
|
||||
def self.record_tag_snapshot! tag, created_by_user:
|
||||
event_type =
|
||||
if tag.nico?
|
||||
tag.nico_tag_versions.exists? ? :update : :create
|
||||
else
|
||||
tag.tag_versions.exists? ? :update : :create
|
||||
end
|
||||
|
||||
record!(tag, event_type:, created_by_user:)
|
||||
end
|
||||
|
||||
def self.record_tag_snapshots! tags, created_by_user:
|
||||
tags.each do |tag|
|
||||
record_tag_snapshot!(tag, created_by_user:)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,62 @@
|
||||
class VersionRecorder
|
||||
EVENT_TYPES = ['create', 'update', 'discard', 'restore'].freeze
|
||||
|
||||
def initialize record:, event_type:, created_by_user:
|
||||
@record = record
|
||||
@event_type = event_type.to_s
|
||||
@created_by_user = created_by_user
|
||||
|
||||
validate_event_type!
|
||||
end
|
||||
|
||||
def record!
|
||||
raise "#{ record_class.name } must be persisted" unless @record.persisted?
|
||||
|
||||
ApplicationRecord.transaction do
|
||||
@record = record_class.unscoped.lock.find(@record.id)
|
||||
latest = latest_version
|
||||
|
||||
if !(latest) && @event_type != 'create'
|
||||
raise "#{ version_class.name } first event must be create"
|
||||
end
|
||||
|
||||
if @event_type == 'create' && latest
|
||||
raise "#{ version_class.name } create event already exists"
|
||||
end
|
||||
|
||||
attrs = snapshot_attributes
|
||||
|
||||
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
||||
|
||||
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def latest_version = versions.order(version_no: :desc).first
|
||||
|
||||
def versions = @record.public_send(version_association)
|
||||
|
||||
def base_attributes latest
|
||||
{ version_no: (latest&.version_no || 0) + 1,
|
||||
event_type: @event_type,
|
||||
created_at: Time.current,
|
||||
created_by_user: @created_by_user }
|
||||
end
|
||||
|
||||
def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v }
|
||||
|
||||
def validate_event_type!
|
||||
return if EVENT_TYPES.include?(@event_type)
|
||||
|
||||
raise ArgumentError, "Invalid event_type: #{ @event_type }"
|
||||
end
|
||||
|
||||
def version_class = raise NotImplementedError
|
||||
def version_association = raise NotImplementedError
|
||||
def record_key = raise NotImplementedError
|
||||
def snapshot_attributes = raise NotImplementedError
|
||||
|
||||
def record_class = @record.class
|
||||
end
|
||||
@@ -18,8 +18,7 @@ Rails.application.configure do
|
||||
# Enable serving of images, stylesheets, and JavaScripts from an asset server.
|
||||
# config.asset_host = "http://assets.example.com"
|
||||
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
config.active_storage.service = :r2
|
||||
|
||||
# Assume all access to the app is happening through a SSL-terminating reverse proxy.
|
||||
config.assume_ssl = true
|
||||
|
||||
@@ -50,4 +50,6 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
Rails.application.routes.default_url_options[:host] = 'www.example.com'
|
||||
end
|
||||
|
||||
@@ -9,9 +9,11 @@ Rails.application.routes.draw do
|
||||
resources :tags, only: [:index, :show, :update] do
|
||||
collection do
|
||||
get :autocomplete
|
||||
get :'with-depth', action: :with_depth
|
||||
|
||||
scope :name do
|
||||
get ':name/deerjikists', action: :deerjikists_by_name
|
||||
get ':name/materials', action: :materials_by_name
|
||||
get ':name', action: :show_by_name
|
||||
end
|
||||
end
|
||||
@@ -47,6 +49,7 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
get :random
|
||||
get :changes
|
||||
get :versions, to: 'post_versions#index'
|
||||
end
|
||||
|
||||
member do
|
||||
@@ -78,5 +81,9 @@ Rails.application.routes.draw do
|
||||
put :watching
|
||||
patch :next_post
|
||||
end
|
||||
|
||||
resources :comments, controller: :theatre_comments, only: [:index, :create]
|
||||
end
|
||||
|
||||
resources :materials, only: [:index, :show, :create, :update, :destroy]
|
||||
end
|
||||
|
||||
@@ -6,29 +6,11 @@ local:
|
||||
service: Disk
|
||||
root: <%= Rails.root.join("storage") %>
|
||||
|
||||
# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
|
||||
# amazon:
|
||||
# service: S3
|
||||
# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
|
||||
# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
|
||||
# region: us-east-1
|
||||
# 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 ]
|
||||
r2:
|
||||
service: S3
|
||||
endpoint: <%= ENV['R2_ENDPOINT'] %>
|
||||
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %>
|
||||
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %>
|
||||
bucket: <%= ENV['R2_BUCKET'] %>
|
||||
region: auto
|
||||
request_checksum_calculation: when_required
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
@@ -0,0 +1,203 @@
|
||||
require 'set'
|
||||
|
||||
|
||||
class CreatePostVersions < ActiveRecord::Migration[8.0]
|
||||
class Post < ActiveRecord::Base
|
||||
self.table_name = 'posts'
|
||||
end
|
||||
|
||||
class PostTag < ActiveRecord::Base
|
||||
self.table_name = 'post_tags'
|
||||
end
|
||||
|
||||
class PostVersion < ActiveRecord::Base
|
||||
self.table_name = 'post_versions'
|
||||
end
|
||||
|
||||
def up
|
||||
create_table :post_versions do |t|
|
||||
t.references :post, null: false, foreign_key: true
|
||||
t.integer :version_no, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :title
|
||||
t.string :url, limit: 768, null: false
|
||||
t.string :thumbnail_base, limit: 2000
|
||||
t.text :tags, null: false
|
||||
t.references :parent, foreign_key: { to_table: :posts }
|
||||
t.datetime :original_created_from
|
||||
t.datetime :original_created_before
|
||||
t.datetime :created_at, null: false
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }
|
||||
|
||||
t.index [:post_id, :version_no], unique: true
|
||||
t.check_constraint 'version_no > 0',
|
||||
name: 'post_versions_version_no_positive'
|
||||
t.check_constraint "event_type IN ('create', 'update', 'discard', 'restore')",
|
||||
name: 'post_versions_event_type_valid'
|
||||
end
|
||||
|
||||
PostVersion.reset_column_information
|
||||
|
||||
say_with_time 'Backfilling post_versions' do
|
||||
Post.find_in_batches(batch_size: 500) do |posts|
|
||||
post_ids = posts.map(&:id)
|
||||
|
||||
post_tag_rows_by_post_id =
|
||||
PostTag
|
||||
.joins('INNER JOIN tags ON tags.id = post_tags.tag_id')
|
||||
.joins('INNER JOIN tag_names ON tag_names.id = tags.tag_name_id')
|
||||
.where(post_id: post_ids)
|
||||
.pluck('post_tags.post_id',
|
||||
'post_tags.created_at',
|
||||
'post_tags.discarded_at',
|
||||
'post_tags.created_user_id',
|
||||
'post_tags.deleted_user_id',
|
||||
'tag_names.name')
|
||||
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||
post_id, created_at, discarded_at, created_user_id, deleted_user_id, tag_name = row
|
||||
h[post_id] << { created_at:,
|
||||
discarded_at:,
|
||||
created_user_id:,
|
||||
deleted_user_id:,
|
||||
tag_name: }
|
||||
end
|
||||
|
||||
rows = []
|
||||
|
||||
posts.each do |post|
|
||||
post_tag_rows = post_tag_rows_by_post_id[post.id]
|
||||
|
||||
events = post_tag_rows.flat_map do |post_tag_row|
|
||||
ary = [[post_tag_row[:created_at],
|
||||
post_tag_row[:created_user_id],
|
||||
:add,
|
||||
post_tag_row[:tag_name]]]
|
||||
|
||||
if post_tag_row[:discarded_at]
|
||||
ary << [post_tag_row[:discarded_at],
|
||||
post_tag_row[:deleted_user_id],
|
||||
:remove,
|
||||
post_tag_row[:tag_name]]
|
||||
end
|
||||
|
||||
ary
|
||||
end
|
||||
|
||||
kind_order = { add: 0, remove: 1 }
|
||||
|
||||
events.sort_by! do |event_at, user_id, kind, tag_name|
|
||||
[event_at, user_id || 0, kind_order.fetch(kind), tag_name]
|
||||
end
|
||||
|
||||
event_buckets = bucket_events(events)
|
||||
|
||||
active_tags = Set.new
|
||||
version_no = 0
|
||||
|
||||
if event_buckets.empty?
|
||||
version_no += 1
|
||||
rows << build_row(post:,
|
||||
version_no:,
|
||||
event_type: 'create',
|
||||
created_at: post.created_at,
|
||||
created_by_user_id: post.uploaded_user_id,
|
||||
tags: [])
|
||||
next
|
||||
end
|
||||
|
||||
first_bucket = event_buckets.first
|
||||
merge_first_bucket_into_create = first_bucket[:first_at] <= post.created_at + 1.second
|
||||
|
||||
if merge_first_bucket_into_create
|
||||
event_buckets.shift
|
||||
apply_bucket!(active_tags, first_bucket)
|
||||
|
||||
version_no += 1
|
||||
rows << build_row(
|
||||
post:,
|
||||
version_no:,
|
||||
event_type: 'create',
|
||||
created_at: post.created_at,
|
||||
created_by_user_id: post.uploaded_user_id || first_bucket[:user_ids].compact.first,
|
||||
tags: active_tags.to_a.sort)
|
||||
else
|
||||
version_no += 1
|
||||
rows << build_row(
|
||||
post:,
|
||||
version_no:,
|
||||
event_type: 'create',
|
||||
created_at: post.created_at,
|
||||
created_by_user_id: post.uploaded_user_id,
|
||||
tags: [])
|
||||
end
|
||||
|
||||
event_buckets.each do |bucket|
|
||||
apply_bucket!(active_tags, bucket)
|
||||
|
||||
version_no += 1
|
||||
rows << build_row(
|
||||
post:,
|
||||
version_no:,
|
||||
event_type: 'update',
|
||||
created_at: bucket[:first_at],
|
||||
created_by_user_id: bucket[:user_ids].compact.first,
|
||||
tags: active_tags.to_a.sort)
|
||||
end
|
||||
end
|
||||
|
||||
PostVersion.insert_all!(rows) if rows.any?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :post_versions
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def bucket_events events
|
||||
buckets = []
|
||||
|
||||
events.each do |event_at, user_id, kind, tag_name|
|
||||
if buckets.empty? || event_at - buckets.last[:last_at] > 1.second
|
||||
buckets << { first_at: event_at,
|
||||
last_at: event_at,
|
||||
user_ids: [user_id],
|
||||
events: [[kind, tag_name]] }
|
||||
else
|
||||
bucket = buckets.last
|
||||
bucket[:last_at] = event_at
|
||||
bucket[:user_ids] << user_id
|
||||
bucket[:events] << [kind, tag_name]
|
||||
end
|
||||
end
|
||||
|
||||
buckets
|
||||
end
|
||||
|
||||
def apply_bucket! active_tags, bucket
|
||||
bucket[:events].each do |kind, tag_name|
|
||||
if kind == :add
|
||||
active_tags.add(tag_name)
|
||||
else
|
||||
active_tags.delete(tag_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def build_row post:, version_no:, event_type:, created_at:, created_by_user_id:, tags:
|
||||
{ post_id: post.id,
|
||||
version_no:,
|
||||
event_type:,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: tags.join(' '),
|
||||
parent_id: post.parent_id,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at:,
|
||||
created_by_user_id: }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
class CreateTagVersions < ActiveRecord::Migration[8.0]
|
||||
class Tag < ActiveRecord::Base
|
||||
self.table_name = 'tags'
|
||||
end
|
||||
|
||||
class TagName < ActiveRecord::Base
|
||||
self.table_name = 'tag_names'
|
||||
end
|
||||
|
||||
class TagImplication < ActiveRecord::Base
|
||||
self.table_name = 'tag_implications'
|
||||
end
|
||||
|
||||
class TagVersion < ActiveRecord::Base
|
||||
self.table_name = 'tag_versions'
|
||||
end
|
||||
|
||||
class NicoTagVersion < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_versions'
|
||||
end
|
||||
|
||||
class NicoTagRelation < ActiveRecord::Base
|
||||
self.table_name = 'nico_tag_relations'
|
||||
end
|
||||
|
||||
def up
|
||||
create_table :tag_versions do |t|
|
||||
t.references :tag, null: false, foreign_key: true, index: false
|
||||
t.integer :version_no, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :name, null: false
|
||||
t.string :category, null: false
|
||||
t.text :aliases, null: false
|
||||
t.text :parent_tag_ids, null: false
|
||||
t.datetime :created_at, null: false
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
|
||||
|
||||
t.index [:tag_id, :version_no], unique: true
|
||||
t.index :created_at
|
||||
t.index [:tag_id, :created_at], order: { created_at: :desc }
|
||||
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
|
||||
t.check_constraint 'version_no > 0',
|
||||
name: 'tag_versions_version_no_positive'
|
||||
end
|
||||
|
||||
create_table :nico_tag_versions do |t|
|
||||
t.references :tag, null: false, foreign_key: true, index: false
|
||||
t.integer :version_no, null: false
|
||||
t.string :event_type, null: false
|
||||
t.string :name, null: false
|
||||
t.text :linked_tags, null: false
|
||||
t.datetime :created_at, null: false
|
||||
t.references :created_by_user, foreign_key: { to_table: :users }, index: false
|
||||
|
||||
t.index [:tag_id, :version_no], unique: true
|
||||
t.index :created_at
|
||||
t.index [:tag_id, :created_at], order: { created_at: :desc }
|
||||
t.index [:created_by_user_id, :created_at], order: { created_at: :desc }
|
||||
t.check_constraint 'version_no > 0',
|
||||
name: 'nico_tag_versions_version_no_positive'
|
||||
end
|
||||
|
||||
TagVersion.reset_column_information
|
||||
say_with_time 'Backfilling tag_versions' do
|
||||
Tag.where(discarded_at: nil)
|
||||
.where.not(category: 'nico')
|
||||
.find_in_batches(batch_size: 500) do |tags|
|
||||
tag_ids = tags.map(&:id)
|
||||
|
||||
tag_implication_rows_by_tag_id =
|
||||
TagImplication
|
||||
.where(tag_id: tag_ids)
|
||||
.pluck(:tag_id, :parent_tag_id)
|
||||
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||
h[row[0]] << row[1]
|
||||
end
|
||||
|
||||
tag_name_rows_by_tag_id =
|
||||
TagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||
.where(tags: { id: tag_ids })
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.each_with_object({ }) do |row, h|
|
||||
h[row[0]] = row[1]
|
||||
end
|
||||
|
||||
tag_alias_rows_by_tag_id =
|
||||
TagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.canonical_id')
|
||||
.where(tags: { id: tag_ids })
|
||||
.where(tag_names: { discarded_at: nil })
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||
h[row[0]] << row[1]
|
||||
end
|
||||
|
||||
TagVersion.insert_all(tags.map { |tag|
|
||||
{ tag_id: tag.id,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: tag_name_rows_by_tag_id[tag.id],
|
||||
category: tag.category,
|
||||
aliases: tag_alias_rows_by_tag_id[tag.id].sort.join(' '),
|
||||
parent_tag_ids: tag_implication_rows_by_tag_id[tag.id].sort.join(' '),
|
||||
created_at: tag.created_at,
|
||||
created_by_user_id: nil }
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
NicoTagVersion.reset_column_information
|
||||
say_with_time 'Backfilling nico_tag_versions' do
|
||||
Tag.where(discarded_at: nil, category: 'nico')
|
||||
.find_in_batches(batch_size: 500) do |tags|
|
||||
tag_ids = tags.map(&:id)
|
||||
|
||||
tag_name_rows_by_tag_id =
|
||||
TagName
|
||||
.joins('INNER JOIN tags ON tags.tag_name_id = tag_names.id')
|
||||
.where(tags: { id: tag_ids })
|
||||
.pluck('tags.id', 'tag_names.name')
|
||||
.each_with_object({ }) do |row, h|
|
||||
h[row[0]] = row[1]
|
||||
end
|
||||
|
||||
nico_tag_relation_rows_by_tag_id =
|
||||
NicoTagRelation
|
||||
.joins('INNER JOIN tags nico_tags ON nico_tags.id = nico_tag_relations.nico_tag_id')
|
||||
.joins('INNER JOIN tags linked_tags ON linked_tags.id = nico_tag_relations.tag_id')
|
||||
.joins('INNER JOIN tag_names ON tag_names.id = linked_tags.tag_name_id')
|
||||
.where(nico_tags: { id: tag_ids })
|
||||
.where(linked_tags: { discarded_at: nil })
|
||||
.where(tag_names: { discarded_at: nil })
|
||||
.pluck('nico_tags.id', 'tag_names.name')
|
||||
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |row, h|
|
||||
h[row[0]] << row[1]
|
||||
end
|
||||
|
||||
NicoTagVersion.insert_all(tags.map { |tag|
|
||||
{ tag_id: tag.id,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: tag_name_rows_by_tag_id[tag.id],
|
||||
linked_tags: nico_tag_relation_rows_by_tag_id[tag.id].sort.join(' '),
|
||||
created_at: tag.created_at,
|
||||
created_by_user_id: nil }
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def down
|
||||
drop_table :nico_tag_versions
|
||||
drop_table :tag_versions
|
||||
end
|
||||
end
|
||||
Generated
+125
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2026_04_19_035400) do
|
||||
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.string "record_type", null: false
|
||||
@@ -56,6 +56,45 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
|
||||
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|
|
||||
t.bigint "nico_tag_id", null: false
|
||||
t.bigint "tag_id", null: false
|
||||
@@ -65,6 +104,21 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.index ["tag_id"], name: "index_nico_tag_relations_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "nico_tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.text "linked_tags", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
|
||||
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.bigint "target_post_id", null: false
|
||||
@@ -93,6 +147,27 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.index ["tag_id"], name: "index_post_tags_on_tag_id"
|
||||
end
|
||||
|
||||
create_table "post_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "post_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "title"
|
||||
t.string "url", limit: 768, null: false
|
||||
t.string "thumbnail_base", limit: 2000
|
||||
t.text "tags", null: false
|
||||
t.bigint "parent_id"
|
||||
t.datetime "original_created_from"
|
||||
t.datetime "original_created_before"
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_by_user_id"], name: "index_post_versions_on_created_by_user_id"
|
||||
t.index ["parent_id"], name: "index_post_versions_on_parent_id"
|
||||
t.index ["post_id", "version_no"], name: "index_post_versions_on_post_id_and_version_no", unique: true
|
||||
t.index ["post_id"], name: "index_post_versions_on_post_id"
|
||||
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
|
||||
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.string "title"
|
||||
t.string "url", limit: 768, null: false
|
||||
@@ -156,6 +231,23 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
|
||||
end
|
||||
|
||||
create_table "tag_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_id", null: false
|
||||
t.integer "version_no", null: false
|
||||
t.string "event_type", null: false
|
||||
t.string "name", null: false
|
||||
t.string "category", null: false
|
||||
t.text "aliases", null: false
|
||||
t.text "parent_tag_ids", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.bigint "created_by_user_id"
|
||||
t.index ["created_at"], name: "index_tag_versions_on_created_at"
|
||||
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
|
||||
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
|
||||
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
|
||||
end
|
||||
|
||||
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
|
||||
t.bigint "tag_name_id", null: false
|
||||
t.string "category", default: "general", null: false
|
||||
@@ -239,6 +331,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.datetime "updated_at", null: false
|
||||
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|
|
||||
t.string "sha256", limit: 64, null: false
|
||||
t.text "body", null: false
|
||||
@@ -254,6 +359,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
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 ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
|
||||
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
|
||||
@@ -292,14 +398,28 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
|
||||
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 "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", column: "nico_tag_id"
|
||||
add_foreign_key "nico_tag_versions", "tags"
|
||||
add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "post_similarities", "posts"
|
||||
add_foreign_key "post_similarities", "posts", column: "target_post_id"
|
||||
add_foreign_key "post_tags", "posts"
|
||||
add_foreign_key "post_tags", "tags"
|
||||
add_foreign_key "post_tags", "users", column: "created_user_id"
|
||||
add_foreign_key "post_tags", "users", column: "deleted_user_id"
|
||||
add_foreign_key "post_versions", "posts"
|
||||
add_foreign_key "post_versions", "posts", column: "parent_id"
|
||||
add_foreign_key "post_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "posts", "posts", column: "parent_id"
|
||||
add_foreign_key "posts", "users", column: "uploaded_user_id"
|
||||
add_foreign_key "settings", "users"
|
||||
@@ -308,6 +428,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
add_foreign_key "tag_names", "tag_names", column: "canonical_id"
|
||||
add_foreign_key "tag_similarities", "tags"
|
||||
add_foreign_key "tag_similarities", "tags", column: "target_tag_id"
|
||||
add_foreign_key "tag_versions", "tags"
|
||||
add_foreign_key "tag_versions", "users", column: "created_by_user_id"
|
||||
add_foreign_key "tags", "tag_names"
|
||||
add_foreign_key "theatre_comments", "theatres"
|
||||
add_foreign_key "theatre_comments", "users"
|
||||
@@ -320,6 +442,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
|
||||
add_foreign_key "user_ips", "users"
|
||||
add_foreign_key "user_post_views", "posts"
|
||||
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", "users", column: "created_user_id"
|
||||
add_foreign_key "wiki_pages", "users", column: "updated_user_id"
|
||||
|
||||
@@ -61,6 +61,9 @@ namespace :nico do
|
||||
original_created_from = original_created_at&.change(sec: 0)
|
||||
original_created_before = original_created_from&.+(1.minute)
|
||||
|
||||
post_created = false
|
||||
post_changed = false
|
||||
|
||||
if post
|
||||
attrs = { title:, original_created_from:, original_created_before: }
|
||||
|
||||
@@ -76,11 +79,13 @@ namespace :nico do
|
||||
end
|
||||
|
||||
post.assign_attributes(attrs)
|
||||
if post.changed?
|
||||
post_changed = post.changed?
|
||||
if post_changed
|
||||
post.save!
|
||||
post.resized_thumbnail! if post.thumbnail.attached?
|
||||
end
|
||||
else
|
||||
post_created = true
|
||||
url = "https://www.nicovideo.jp/watch/#{ code }"
|
||||
thumbnail_base = fetch_thumbnail.(url) rescue nil
|
||||
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
|
||||
@@ -110,6 +115,10 @@ namespace :nico do
|
||||
datum['tags'].each do |raw|
|
||||
name = TagNameSanitisationRule.sanitise("nico:#{ raw }")
|
||||
tag = Tag.find_or_create_by_tag_name!(name, category: :nico)
|
||||
|
||||
event_type = tag.nico_tag_versions.exists? ? :update : :create
|
||||
NicoTagVersionRecorder.record!(tag:, event_type:, created_by_user: nil)
|
||||
|
||||
desired_nico_tag_based_ids << tag.id
|
||||
|
||||
# 新たに記載される外部タグと連携される内部タグを記載
|
||||
@@ -140,6 +149,13 @@ namespace :nico do
|
||||
desired_all_tag_ids.uniq!
|
||||
|
||||
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids)
|
||||
|
||||
if post_created
|
||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: nil)
|
||||
elsif post_changed || kept_tag_ids != desired_all_tag_ids.to_set
|
||||
PostVersionRecorder.ensure_snapshot!(post, created_by_user: nil)
|
||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
FactoryBot.define do
|
||||
factory :theatre_comment do
|
||||
association :theatre
|
||||
association :user
|
||||
sequence (:no) { |n| n }
|
||||
content { 'test comment' }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,11 @@
|
||||
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
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PostVersion, type: :model do
|
||||
let!(:tag_name) { TagName.create!(name: 'post_version_spec_tag') }
|
||||
let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) }
|
||||
|
||||
let!(:post_record) do
|
||||
Post.create!(title: 'spec post', url: 'https://example.com/post-version-spec').tap do |post|
|
||||
PostTag.create!(post: post, tag: tag)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:post_version) do
|
||||
PostVersion.create!(
|
||||
post: post_record,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: post_record.title,
|
||||
url: post_record.url,
|
||||
thumbnail_base: post_record.thumbnail_base,
|
||||
tags: post_record.snapshot_tag_names.join(' '),
|
||||
parent: post_record.parent,
|
||||
original_created_from: post_record.original_created_from,
|
||||
original_created_before: post_record.original_created_before,
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
end
|
||||
|
||||
it 'is read only after create' do
|
||||
expect do
|
||||
post_version.update!(title: 'changed')
|
||||
end.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
it 'cannot be destroyed' do
|
||||
expect do
|
||||
post_version.destroy!
|
||||
end.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
end
|
||||
@@ -145,5 +145,70 @@ RSpec.describe Tag, type: :model do
|
||||
expect(target_tag.reload.post_count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
post.snapshot_tag_names.join(' ')
|
||||
end
|
||||
|
||||
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||
PostVersion.create!(
|
||||
post: post,
|
||||
version_no: version_no,
|
||||
event_type: event_type,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: snapshot_tags(post),
|
||||
parent: post.parent,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: Time.current,
|
||||
created_by_user: created_by_user
|
||||
)
|
||||
end
|
||||
|
||||
context 'when post versions are enabled' do
|
||||
let!(:source_post_tag) { PostTag.create!(post: post_record, tag: source_tag) }
|
||||
let!(:unaffected_post) do
|
||||
Post.create!(url: 'https://example.com/posts/2', title: 'unaffected post')
|
||||
end
|
||||
|
||||
before do
|
||||
create_post_version_for!(post_record)
|
||||
create_post_version_for!(unaffected_post)
|
||||
end
|
||||
|
||||
it 'creates an update post_version only for affected posts' do
|
||||
expect {
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
}.to change(PostVersion, :count).by(1)
|
||||
|
||||
affected_versions = post_record.reload.post_versions.order(:version_no)
|
||||
expect(affected_versions.pluck(:version_no)).to eq([1, 2])
|
||||
|
||||
latest = affected_versions.last
|
||||
expect(latest.event_type).to eq('update')
|
||||
expect(latest.created_by_user).to be_nil
|
||||
expect(latest.tags).to eq(snapshot_tags(post_record.reload))
|
||||
|
||||
expect(unaffected_post.reload.post_versions.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the source tag has no active post_tags' do
|
||||
let!(:another_post) do
|
||||
Post.create!(url: 'https://example.com/posts/3', title: 'another post')
|
||||
end
|
||||
|
||||
before do
|
||||
create_post_version_for!(another_post)
|
||||
end
|
||||
|
||||
it 'does not create any post_version' do
|
||||
expect {
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
}.not_to change(PostVersion, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe VersionRecord, type: :model do
|
||||
let!(:tag) { create(:tag, name: 'version_record_tag') }
|
||||
let!(:nico_tag) { create(:tag, :nico, name: 'nico:version_record_tag') }
|
||||
|
||||
it 'makes TagVersion read only after create' do
|
||||
version = TagVersion.create!(
|
||||
tag: tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
aliases: '',
|
||||
parent_tag_ids: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.update!(name: 'changed')
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
it 'prevents TagVersion destroy' do
|
||||
version = TagVersion.create!(
|
||||
tag: tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: tag.name,
|
||||
category: tag.category,
|
||||
aliases: '',
|
||||
parent_tag_ids: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.destroy!
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
it 'makes NicoTagVersion read only after create' do
|
||||
version = NicoTagVersion.create!(
|
||||
tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: nico_tag.name,
|
||||
linked_tags: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.update!(name: 'nico:changed')
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
|
||||
it 'prevents NicoTagVersion destroy' do
|
||||
version = NicoTagVersion.create!(
|
||||
tag: nico_tag,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
name: nico_tag.name,
|
||||
linked_tags: '',
|
||||
created_at: Time.current,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
expect {
|
||||
version.destroy!
|
||||
}.to raise_error(ActiveRecord::ReadOnlyRecord)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,378 @@
|
||||
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
|
||||
@@ -14,6 +14,7 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
|
||||
describe 'PATCH /tags/nico/:id' do
|
||||
let(:member) { create(:user, :member) }
|
||||
let(:admin) { create(:user, :admin) }
|
||||
let(:nico_tag) { create(:tag, :nico) }
|
||||
|
||||
it '401 when not logged in' do
|
||||
@@ -34,5 +35,59 @@ RSpec.describe 'NicoTags', type: :request do
|
||||
patch "/tags/nico/#{non_nico.id}", params: { tags: 'a b' }
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
|
||||
it '200 and updates linked tags while recording tag versions' do
|
||||
sign_in_as(admin)
|
||||
|
||||
nico_tag_name = TagName.create!(name: 'nico:nico_tags_spec_source')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
|
||||
linked_a_name = TagName.create!(name: 'nico_linked_a')
|
||||
linked_a = Tag.create!(tag_name: linked_a_name, category: :general)
|
||||
|
||||
linked_b_name = TagName.create!(name: 'nico_linked_b')
|
||||
linked_b = Tag.create!(tag_name: linked_b_name, category: :general)
|
||||
|
||||
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: admin)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: {
|
||||
tags: " #{linked_a.name}\n#{linked_b.name} "
|
||||
}
|
||||
}.to change(TagVersion, :count).by(2)
|
||||
.and change(NicoTagVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
names = json.map { |t| t['name'] }
|
||||
expect(names).to match_array(['nico_linked_a', 'nico_linked_b'])
|
||||
|
||||
linked_versions = TagVersion.where(tag: [linked_a, linked_b]).order(:tag_id)
|
||||
expect(linked_versions.map(&:event_type)).to eq(['create', 'create'])
|
||||
expect(linked_versions.map(&:created_by_user_id)).to all(eq(admin.id))
|
||||
|
||||
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.last.linked_tags.split).to match_array([
|
||||
'nico_linked_a',
|
||||
'nico_linked_b'
|
||||
])
|
||||
expect(versions.last.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
|
||||
it '400 when linked tag normalises to nico tag' do
|
||||
sign_in_as(member)
|
||||
|
||||
other_nico = create(:tag, :nico, name: 'nico:linked_ng')
|
||||
TagName.create!(name: 'linked_ng_alias', canonical: other_nico.tag_name)
|
||||
|
||||
TagVersioning.ensure_snapshot!(nico_tag, created_by_user: member)
|
||||
|
||||
expect {
|
||||
patch "/tags/nico/#{nico_tag.id}", params: { tags: 'linked_ng_alias' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
require 'rails_helper'
|
||||
require 'set'
|
||||
|
||||
include ActiveSupport::Testing::TimeHelpers
|
||||
|
||||
RSpec.describe 'Posts API', type: :request do
|
||||
# create / update で thumbnail.attach は走るが、
|
||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||
@@ -756,6 +756,218 @@ RSpec.describe 'Posts API', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /posts/versions' do
|
||||
let(:member) { create(:user, :member, name: 'version member') }
|
||||
|
||||
let(:t_v1) { Time.zone.local(2020, 1, 1, 12, 0, 0) }
|
||||
let(:t_v2) { Time.zone.local(2020, 1, 2, 12, 0, 0) }
|
||||
let(:t_other) { Time.zone.local(2020, 1, 3, 12, 0, 0) }
|
||||
|
||||
let(:oc_from) { Time.zone.local(2019, 12, 31, 0, 0, 0) }
|
||||
let(:oc_before) { Time.zone.local(2020, 1, 1, 0, 0, 0) }
|
||||
|
||||
let!(:tag_name2) { TagName.create!(name: 'spec_tag_2') }
|
||||
let!(:tag2) { Tag.create!(tag_name: tag_name2, category: :general) }
|
||||
|
||||
def snapshot_tags(post)
|
||||
post.snapshot_tag_names.join(' ')
|
||||
end
|
||||
|
||||
def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:)
|
||||
PostVersion.create!(
|
||||
post: post,
|
||||
version_no: version_no,
|
||||
event_type: event_type,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: snapshot_tags(post),
|
||||
parent: post.parent,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: created_at,
|
||||
created_by_user: created_by_user
|
||||
)
|
||||
end
|
||||
|
||||
let!(:v1) do
|
||||
travel_to(t_v1) do
|
||||
create_post_version!(
|
||||
post_record,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
created_by_user: member,
|
||||
created_at: t_v1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:v2) do
|
||||
post_record.post_tags.kept.find_by!(tag: tag).discard_by!(member)
|
||||
PostTag.create!(post: post_record, tag: tag2, created_user: member)
|
||||
post_record.update!(
|
||||
title: 'updated spec post',
|
||||
original_created_from: oc_from,
|
||||
original_created_before: oc_before
|
||||
)
|
||||
|
||||
travel_to(t_v2) do
|
||||
create_post_version!(
|
||||
post_record.reload,
|
||||
version_no: 2,
|
||||
event_type: 'update',
|
||||
created_by_user: member,
|
||||
created_at: t_v2
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
let!(:other_post_version) do
|
||||
other_post = Post.create!(
|
||||
title: 'other versioned post',
|
||||
url: 'https://example.com/other-versioned'
|
||||
)
|
||||
PostTag.create!(post: other_post, tag: tag)
|
||||
|
||||
travel_to(t_other) do
|
||||
create_post_version!(
|
||||
other_post,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
created_by_user: member,
|
||||
created_at: t_other
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns versions for the specified post in reverse chronological order' do
|
||||
get '/posts/versions', params: { post: post_record.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json).to include('versions', 'count')
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
|
||||
versions = json.fetch('versions')
|
||||
expect(versions.map { |v| v['post_id'] }.uniq).to eq([post_record.id])
|
||||
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
||||
|
||||
latest = versions.first
|
||||
expect(latest).to include(
|
||||
'post_id' => post_record.id,
|
||||
'version_no' => 2,
|
||||
'event_type' => 'update',
|
||||
'created_by_user' => {
|
||||
'id' => member.id,
|
||||
'name' => member.name
|
||||
}
|
||||
)
|
||||
|
||||
expect(latest.fetch('title')).to eq(
|
||||
'current' => 'updated spec post',
|
||||
'prev' => 'spec post'
|
||||
)
|
||||
expect(latest.fetch('url')).to eq(
|
||||
'current' => 'https://example.com/spec',
|
||||
'prev' => 'https://example.com/spec'
|
||||
)
|
||||
expect(latest.fetch('thumbnail')).to eq(
|
||||
'current' => nil,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('thumbnail_base')).to eq(
|
||||
'current' => nil,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('tags')).to include(
|
||||
{ 'name' => 'spec_tag_2', 'type' => 'added' },
|
||||
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
||||
)
|
||||
expect(latest.fetch('original_created_from')).to eq(
|
||||
'current' => oc_from.iso8601,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('original_created_before')).to eq(
|
||||
'current' => oc_before.iso8601,
|
||||
'prev' => nil
|
||||
)
|
||||
expect(latest.fetch('created_at')).to eq(t_v2.iso8601)
|
||||
|
||||
first = versions.second
|
||||
expect(first).to include(
|
||||
'post_id' => post_record.id,
|
||||
'version_no' => 1,
|
||||
'event_type' => 'create',
|
||||
'created_by_user' => {
|
||||
'id' => member.id,
|
||||
'name' => member.name
|
||||
}
|
||||
)
|
||||
expect(first.fetch('title')).to eq(
|
||||
'current' => 'spec post',
|
||||
'prev' => nil
|
||||
)
|
||||
expect(first.fetch('tags')).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'added' }
|
||||
)
|
||||
expect(first.fetch('created_at')).to eq(t_v1.iso8601)
|
||||
end
|
||||
|
||||
it 'filters versions by tag when the current snapshot includes the tag' do
|
||||
get '/posts/versions', params: { post: post_record.id, tag: tag2.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(1)
|
||||
|
||||
versions = json.fetch('versions')
|
||||
expect(versions.size).to eq(1)
|
||||
expect(versions[0]['post_id']).to eq(post_record.id)
|
||||
expect(versions[0]['version_no']).to eq(2)
|
||||
expect(versions[0]['tags']).to include(
|
||||
{ 'name' => 'spec_tag_2', 'type' => 'added' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'filters versions by tag when the tag exists in either current or previous snapshot' do
|
||||
get '/posts/versions', params: { post: post_record.id, tag: tag.id }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
|
||||
versions = json.fetch('versions')
|
||||
expect(versions.map { |v| v['post_id'] }).to all(eq(post_record.id))
|
||||
expect(versions.map { |v| v['version_no'] }).to eq([2, 1])
|
||||
|
||||
latest = versions[0]
|
||||
first = versions[1]
|
||||
|
||||
expect(latest['tags']).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'removed' }
|
||||
)
|
||||
expect(first['tags']).to include(
|
||||
{ 'name' => 'spec_tag', 'type' => 'added' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty when tag does not exist' do
|
||||
get '/posts/versions', params: { tag: 999_999_999 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('versions')).to eq([])
|
||||
expect(json.fetch('count')).to eq(0)
|
||||
end
|
||||
|
||||
it 'clamps page and limit to at least 1' do
|
||||
get '/posts/versions', params: { post: post_record.id, page: 0, limit: 0 }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('count')).to eq(2)
|
||||
|
||||
versions = json.fetch('versions')
|
||||
expect(versions.size).to eq(1)
|
||||
expect(versions[0]['version_no']).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /posts/:id/viewed' do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
@@ -795,4 +1007,176 @@ RSpec.describe 'Posts API', type: :request do
|
||||
expect(user.reload.viewed?(post_record)).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'post versioning' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
def snapshot_tags(post)
|
||||
post.snapshot_tag_names.join(' ')
|
||||
end
|
||||
|
||||
def create_post_version_for!(post)
|
||||
PostVersion.create!(
|
||||
post: post,
|
||||
version_no: 1,
|
||||
event_type: 'create',
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: snapshot_tags(post),
|
||||
parent: post.parent,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: post.created_at,
|
||||
created_by_user: post.uploaded_user
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates version 1 on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
expect do
|
||||
post '/posts', params: {
|
||||
title: 'versioned post',
|
||||
url: 'https://example.com/versioned-post',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload
|
||||
}
|
||||
end.to change(PostVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
created_post = Post.find(json.fetch('id'))
|
||||
version = PostVersion.find_by!(post: created_post, version_no: 1)
|
||||
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.title).to eq('versioned post')
|
||||
expect(version.url).to eq('https://example.com/versioned-post')
|
||||
expect(version.created_by_user_id).to eq(member.id)
|
||||
expect(version.tags).to eq(snapshot_tags(created_post))
|
||||
end
|
||||
|
||||
it 'creates next version on PUT /posts/:id when snapshot changes' do
|
||||
sign_in_as(member)
|
||||
create_post_version_for!(post_record)
|
||||
|
||||
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
||||
Tag.create!(tag_name: tag_name2, category: :general)
|
||||
|
||||
expect do
|
||||
put "/posts/#{post_record.id}", params: {
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag_2'
|
||||
}
|
||||
end.to change(PostVersion, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
version = post_record.reload.post_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(2)
|
||||
expect(version.event_type).to eq('update')
|
||||
expect(version.title).to eq('updated title')
|
||||
expect(version.created_by_user_id).to eq(member.id)
|
||||
expect(version.tags).to eq(snapshot_tags(post_record.reload))
|
||||
end
|
||||
|
||||
it 'does not create a new version on PUT /posts/:id when snapshot is unchanged' do
|
||||
sign_in_as(member)
|
||||
|
||||
PostTag.create!(post: post_record, tag: Tag.no_deerjikist)
|
||||
create_post_version_for!(post_record.reload)
|
||||
|
||||
expect {
|
||||
put "/posts/#{post_record.id}", params: {
|
||||
title: post_record.title,
|
||||
tags: 'spec_tag'
|
||||
}
|
||||
}.not_to change(PostVersion, :count)
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
version = post_record.reload.post_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.tags).to eq(snapshot_tags(post_record))
|
||||
end
|
||||
|
||||
it 'does not create a version when POST /posts is invalid' do
|
||||
sign_in_as(member)
|
||||
|
||||
expect do
|
||||
post '/posts', params: {
|
||||
title: 'invalid post',
|
||||
url: 'ぼざクリタグ広場',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload
|
||||
}
|
||||
end.not_to change(PostVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
|
||||
it 'does not create a version when PUT /posts/:id is invalid' do
|
||||
sign_in_as(member)
|
||||
create_post_version_for!(post_record)
|
||||
|
||||
expect do
|
||||
put "/posts/#{post_record.id}", params: {
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag',
|
||||
original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601,
|
||||
original_created_before: Time.zone.local(2020, 1, 1, 0, 0, 0).iso8601
|
||||
}
|
||||
end.not_to change(PostVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tag versioning from post write actions' do
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
it 'creates tag snapshot for normalised tags on POST /posts' do
|
||||
sign_in_as(member)
|
||||
|
||||
expect {
|
||||
post '/posts', params: {
|
||||
title: 'tag versioned post',
|
||||
url: 'https://example.com/tag-versioned-post',
|
||||
tags: 'spec_tag',
|
||||
thumbnail: dummy_upload
|
||||
}
|
||||
}.to change { tag.reload.tag_versions.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
|
||||
version = tag.reload.tag_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.name).to eq('spec_tag')
|
||||
expect(version.category).to eq('general')
|
||||
expect(version.created_by_user_id).to eq(member.id)
|
||||
end
|
||||
|
||||
it 'creates tag snapshot for normalised tags on PUT /posts/:id' do
|
||||
sign_in_as(member)
|
||||
|
||||
tag_name2 = TagName.create!(name: 'spec_tag_2')
|
||||
tag2 = Tag.create!(tag_name: tag_name2, category: :general)
|
||||
|
||||
expect {
|
||||
put "/posts/#{post_record.id}", params: {
|
||||
title: 'updated title',
|
||||
tags: 'spec_tag_2'
|
||||
}
|
||||
}.to change { tag2.reload.tag_versions.count }.by(1)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
version = tag2.reload.tag_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.name).to eq('spec_tag_2')
|
||||
expect(version.created_by_user_id).to eq(member.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -58,15 +58,47 @@ RSpec.describe "TagChildren", type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context "when Tag.find raises (invalid ids) it still returns 204" do
|
||||
context "when Tag.find raises (invalid ids)" do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let(:parent_id) { -1 }
|
||||
let(:child_id) { -1 }
|
||||
|
||||
it "returns 204 (rescue nil)" do
|
||||
it "returns 404" do
|
||||
do_request
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parent is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400 and does not create relation' do
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change(TagImplication, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when child is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:child) { create(:tag, :nico, name: 'nico:child_ng') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400 and does not create relation' do
|
||||
expect {
|
||||
do_request
|
||||
}.not_to change(TagImplication, :count)
|
||||
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -116,17 +148,57 @@ RSpec.describe "TagChildren", type: :request do
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
end
|
||||
|
||||
it 'records create and update versions for child tag' do
|
||||
expect {
|
||||
do_request
|
||||
}.to change(TagVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
|
||||
versions = child.reload.tag_versions.order(:version_no)
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.first.parent_tag_ids.split).to include(parent.id.to_s)
|
||||
expect(versions.second.parent_tag_ids).to eq('')
|
||||
expect(versions.second.created_by_user_id).to eq(admin.id)
|
||||
end
|
||||
end
|
||||
|
||||
context "when Tag.find raises (invalid ids) it still returns 204" do
|
||||
context "when Tag.find raises (invalid ids)" do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let(:parent_id) { -1 }
|
||||
let(:child_id) { -1 }
|
||||
|
||||
it "returns 204 (rescue nil)" do
|
||||
it "returns 404" do
|
||||
do_request
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parent is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:parent) { create(:tag, :nico, name: 'nico:parent_ng_delete') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400' do
|
||||
do_request
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when child is nico' do
|
||||
before { stub_current_user(admin) }
|
||||
|
||||
let!(:child) { create(:tag, :nico, name: 'nico:child_ng_delete') }
|
||||
let(:parent_id) { parent.id }
|
||||
let(:child_id) { child.id }
|
||||
|
||||
it 'returns 400' do
|
||||
do_request
|
||||
expect(response).to have_http_status(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,6 +19,17 @@ RSpec.describe 'Tags API', type: :request do
|
||||
response_tags.map { |t| t.fetch('name') }
|
||||
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
|
||||
it 'returns tags with count and metadata' do
|
||||
get '/tags'
|
||||
@@ -353,10 +364,211 @@ RSpec.describe 'Tags API', type: :request do
|
||||
expect(response.status).to be_in([404, 500])
|
||||
end
|
||||
|
||||
it 'バリデーションで update! が失敗したら(通常は 422 か 500)' do
|
||||
patch "/tags/#{ tag.id }", params: { name: 'new', category: 'nico' }
|
||||
expect(response.status).to be_in([422, 500])
|
||||
it 'nico category への変更は 422 を返す' do
|
||||
patch "/tags/#{tag.id}", params: { name: 'new', category: 'nico' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(tag.reload.name).to eq('spec_tag')
|
||||
expect(tag.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'creates initial and update tag versions when name and category change' do
|
||||
expect {
|
||||
patch "/tags/#{tag.id}", params: { name: 'new_tag_name', category: 'meme' }
|
||||
}.to change(TagVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
versions = tag.reload.tag_versions.order(:version_no)
|
||||
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
|
||||
expect(versions.first.name).to eq('spec_tag')
|
||||
expect(versions.first.category).to eq('general')
|
||||
expect(versions.first.aliases.split).to include('unko')
|
||||
|
||||
expect(versions.second.name).to eq('new_tag_name')
|
||||
expect(versions.second.category).to eq('meme')
|
||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||
end
|
||||
|
||||
it 'returns 422 when changing normal tag category to nico' do
|
||||
expect {
|
||||
patch "/tags/#{tag.id}", params: { category: 'nico' }
|
||||
}.not_to change(TagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'creates nico tag versions when updating nico tag name' do
|
||||
nico_tag_name = TagName.create!(name: 'nico:tags_spec_source')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{nico_tag.id}", params: { name: 'nico:tags_spec_renamed' }
|
||||
}.to change(NicoTagVersion, :count).by(2)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
|
||||
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.first.name).to eq('nico:tags_spec_source')
|
||||
expect(versions.second.name).to eq('nico:tags_spec_renamed')
|
||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||
end
|
||||
|
||||
it 'returns 422 when changing nico tag category to normal category' do
|
||||
nico_tag_name = TagName.create!(name: 'nico:category_change_ng')
|
||||
nico_tag = Tag.create!(tag_name: nico_tag_name, category: :nico)
|
||||
|
||||
expect {
|
||||
patch "/tags/#{nico_tag.id}", params: { category: 'general' }
|
||||
}.not_to change(NicoTagVersion, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(nico_tag.reload.category).to eq('nico')
|
||||
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
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
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,11 +117,18 @@ RSpec.describe 'Theatres API', type: :request do
|
||||
expect(theatre.host_user_id).to eq(member.id)
|
||||
expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now)
|
||||
|
||||
expect(json).to eq(
|
||||
expect(json).to include(
|
||||
'host_flg' => true,
|
||||
'post_id' => nil,
|
||||
'post_started_at' => nil
|
||||
)
|
||||
|
||||
expect(json.fetch('watching_users')).to contain_exactly(
|
||||
{
|
||||
'id' => member.id,
|
||||
'name' => 'member user'
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -167,11 +174,22 @@ RSpec.describe 'Theatres API', type: :request do
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(theatre.reload.host_user_id).to eq(other_user.id)
|
||||
|
||||
expect(json).to eq(
|
||||
expect(json).to include(
|
||||
'host_flg' => false,
|
||||
'post_id' => 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
|
||||
|
||||
|
||||
@@ -90,4 +90,232 @@ RSpec.describe "nico:sync" do
|
||||
expect(active_names).to include("nico:NEW")
|
||||
expect(active_names).not_to include("nico:OLD")
|
||||
end
|
||||
|
||||
def snapshot_tags(post)
|
||||
post.snapshot_tag_names.join(' ')
|
||||
end
|
||||
|
||||
def create_post_version_for!(post, version_no: 1, event_type: 'create', created_by_user: nil)
|
||||
PostVersion.create!(
|
||||
post: post,
|
||||
version_no: version_no,
|
||||
event_type: event_type,
|
||||
title: post.title,
|
||||
url: post.url,
|
||||
thumbnail_base: post.thumbnail_base,
|
||||
tags: snapshot_tags(post),
|
||||
parent: post.parent,
|
||||
original_created_from: post.original_created_from,
|
||||
original_created_before: post.original_created_before,
|
||||
created_at: Time.current,
|
||||
created_by_user: created_by_user
|
||||
)
|
||||
end
|
||||
|
||||
it '新規 post 作成時に version 1 を作る' do
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
Tag.niconico
|
||||
Tag.video
|
||||
Tag.no_deerjikist
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 't',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.to change(PostVersion, :count).by(1)
|
||||
|
||||
post = Post.find_by!(url: 'https://www.nicovideo.jp/watch/sm9')
|
||||
version = post.post_versions.order(:version_no).last
|
||||
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.created_by_user).to be_nil
|
||||
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||
end
|
||||
|
||||
it '既存 post の内容または tags が変わったとき update version を作る' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
kept_general = create_tag!('spec_kept', category: 'general')
|
||||
PostTag.create!(post: post, tag: kept_general)
|
||||
create_post_version_for!(post)
|
||||
|
||||
linked = create_tag!('spec_linked', category: 'general')
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
link_nico_to_tag!(nico, linked)
|
||||
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
Tag.no_deerjikist
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 't',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.to change(PostVersion, :count).by(1)
|
||||
|
||||
version = post.reload.post_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(2)
|
||||
expect(version.event_type).to eq('update')
|
||||
expect(version.created_by_user).to be_nil
|
||||
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||
end
|
||||
|
||||
it '既存 post に差分が無いときは新しい version を作らない' do
|
||||
nico = create_tag!('nico:AAA', category: 'nico')
|
||||
no_deerjikist = create_tag!('ニジラー情報不詳', category: 'meta')
|
||||
|
||||
post = Post.create!(
|
||||
title: 't',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil,
|
||||
original_created_from: Time.iso8601('2026-01-01T03:34:00Z'),
|
||||
original_created_before: Time.iso8601('2026-01-01T03:35:00Z')
|
||||
)
|
||||
|
||||
PostTag.create!(post: post, tag: nico)
|
||||
PostTag.create!(post: post, tag: no_deerjikist)
|
||||
create_post_version_for!(post)
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 't',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.not_to change(PostVersion, :count)
|
||||
|
||||
version = post.reload.post_versions.order(:version_no).last
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.tags).to eq(snapshot_tags(post.reload))
|
||||
end
|
||||
|
||||
it '新規 nico tag に nico tag version を作る' do
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
Tag.niconico
|
||||
Tag.video
|
||||
Tag.no_deerjikist
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 't',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.to change(NicoTagVersion, :count).by(1)
|
||||
|
||||
nico_tag = Tag.joins(:tag_name).find_by!(tag_names: { name: 'nico:AAA' })
|
||||
version = nico_tag.nico_tag_versions.order(:version_no).last
|
||||
|
||||
expect(version.version_no).to eq(1)
|
||||
expect(version.event_type).to eq('create')
|
||||
expect(version.name).to eq('nico:AAA')
|
||||
expect(version.created_by_user).to be_nil
|
||||
end
|
||||
|
||||
it '既存 post に version が無い場合は create snapshot を補う' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
kept_general = create_tag!('spec_kept_without_version', category: 'general')
|
||||
PostTag.create!(post: post, tag: kept_general)
|
||||
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
Tag.no_deerjikist
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 'changed title',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.to change { post.reload.post_versions.count }.by(1)
|
||||
|
||||
versions = post.reload.post_versions.order(:version_no)
|
||||
|
||||
expect(versions.map(&:event_type)).to eq(['create'])
|
||||
expect(versions.first.title).to eq('changed title')
|
||||
expect(versions.first.tags).to eq(snapshot_tags(post.reload))
|
||||
end
|
||||
|
||||
it '既存 version がある post には update version を作る' do
|
||||
post = Post.create!(
|
||||
title: 'old',
|
||||
url: 'https://www.nicovideo.jp/watch/sm9',
|
||||
uploaded_user: nil
|
||||
)
|
||||
|
||||
kept_general = create_tag!('spec_kept_with_version', category: 'general')
|
||||
PostTag.create!(post: post, tag: kept_general)
|
||||
|
||||
PostVersionRecorder.record!(
|
||||
post: post,
|
||||
event_type: :create,
|
||||
created_by_user: nil
|
||||
)
|
||||
|
||||
Tag.bot
|
||||
Tag.tagme
|
||||
Tag.no_deerjikist
|
||||
|
||||
stub_python([{
|
||||
'code' => 'sm9',
|
||||
'title' => 'changed title',
|
||||
'tags' => ['AAA'],
|
||||
'uploaded_at' => '2026-01-01 12:34:56'
|
||||
}])
|
||||
|
||||
allow(URI).to receive(:open).and_return(StringIO.new('<html></html>'))
|
||||
|
||||
expect {
|
||||
run_rake_task('nico:sync')
|
||||
}.to change { post.reload.post_versions.count }.by(1)
|
||||
|
||||
versions = post.reload.post_versions.order(:version_no)
|
||||
|
||||
expect(versions.map(&:event_type)).to eq(['create', 'update'])
|
||||
expect(versions.first.title).to eq('old')
|
||||
expect(versions.second.title).to eq('changed title')
|
||||
expect(versions.second.tags).to eq(snapshot_tags(post.reload))
|
||||
end
|
||||
end
|
||||
|
||||
Generated
+925
-181
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource-variable/noto-sans-jp": "^5.2.9",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@mdx-js/rollup": "^3.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-toast": "^1.2.14",
|
||||
@@ -37,13 +39,15 @@
|
||||
"react-youtube": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zustand": "^5.0.8",
|
||||
"unist-util-visit-parents": "^6.0.1"
|
||||
"unist-util-visit-parents": "^6.0.1",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/axios": "^0.14.4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
|
||||
+47
-29
@@ -1,4 +1,4 @@
|
||||
import { AnimatePresence, LayoutGroup } from 'framer-motion'
|
||||
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { BrowserRouter,
|
||||
Navigate,
|
||||
@@ -10,8 +10,15 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||
import TopNav from '@/components/TopNav'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
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 MorePage from '@/pages/MorePage'
|
||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
import PostDetailPage from '@/pages/posts/PostDetailPage'
|
||||
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
|
||||
import PostListPage from '@/pages/posts/PostListPage'
|
||||
@@ -39,30 +46,36 @@ const RouteTransitionWrapper = ({ user, setUser }: {
|
||||
const location = useLocation ()
|
||||
|
||||
return (
|
||||
<LayoutGroup id="gallery-shared">
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="/tags" element={<TagListPage/>}/>
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
|
||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</LayoutGroup>)
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="/tags" element={<TagListPage/>}/>
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<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/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
<Route path="/tos" element={<TOSPage/>}/>
|
||||
<Route path="/more" element={<MorePage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</Routes>
|
||||
</AnimatePresence>)
|
||||
}
|
||||
|
||||
|
||||
@@ -120,10 +133,15 @@ export default (() => {
|
||||
<>
|
||||
<RouteBlockerOverlay/>
|
||||
<BrowserRouter>
|
||||
<div className="flex flex-col h-screen w-screen">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</div>
|
||||
<LayoutGroup>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||
</motion.div>
|
||||
</LayoutGroup>
|
||||
<Toaster/>
|
||||
</BrowserRouter>
|
||||
</>)
|
||||
|
||||
@@ -90,7 +90,9 @@ export default (({ tag, nestLevel, pathKey, parentTagId, suppressClickRef, sp }:
|
||||
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
|
||||
{...attributes}
|
||||
{...listeners}>
|
||||
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||
<motion.div
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||
</motion.div>
|
||||
</div>)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
@@ -62,7 +62,7 @@ export default (({ post, onSave }: Props) => {
|
||||
<Label>タイトル</Label>
|
||||
<input type="text"
|
||||
className="w-full border rounded p-2"
|
||||
value={title}
|
||||
value={title ?? ''}
|
||||
onChange={ev => setTitle (ev.target.value)}/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => {
|
||||
cardRef.current.style.zIndex = ''
|
||||
cardRef.current.style.position = ''
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
|
||||
@@ -71,7 +71,8 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
|| ev.metaKey
|
||||
|| ev.ctrlKey
|
||||
|| ev.shiftKey
|
||||
|| ev.altKey)
|
||||
|| ev.altKey
|
||||
|| (rest.target && rest.target !== '_self'))
|
||||
return
|
||||
|
||||
ev.preventDefault ()
|
||||
|
||||
@@ -313,7 +313,9 @@ export default (({ post, sp }: Props) => {
|
||||
{CATEGORIES.map ((cat: Category) => ((tags[cat] ?? []).length > 0 || dragging) && (
|
||||
<div className="my-3" key={cat}>
|
||||
<SubsectionTitle>
|
||||
<motion.div layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}>
|
||||
<motion.div
|
||||
layoutId={`tag-${ sp ? 'sp-' : '' }${ cat }`}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
||||
{CATEGORY_NAMES[cat]}
|
||||
</motion.div>
|
||||
</SubsectionTitle>
|
||||
@@ -325,7 +327,9 @@ export default (({ post, sp }: Props) => {
|
||||
</ul>
|
||||
</div>))}
|
||||
{post && (
|
||||
<motion.div layoutId={`post-info-${ sp }`}>
|
||||
<motion.div
|
||||
layoutId={`post-info-${ sp }`}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
||||
<SectionTitle>情報</SectionTitle>
|
||||
<ul>
|
||||
<li>Id.: {post.id}</li>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ComponentProps, FC, HTMLAttributes } from 'react'
|
||||
@@ -13,8 +10,7 @@ type CommonProps = {
|
||||
tag: Tag
|
||||
nestLevel?: number
|
||||
withWiki?: boolean
|
||||
withCount?: boolean
|
||||
prefetch?: boolean }
|
||||
withCount?: boolean }
|
||||
|
||||
type PropsWithLink =
|
||||
& CommonProps
|
||||
@@ -36,37 +32,7 @@ export default (({ tag,
|
||||
linkFlg = true,
|
||||
withWiki = true,
|
||||
withCount = true,
|
||||
prefetch = false,
|
||||
...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 (
|
||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
|
||||
@@ -79,19 +45,39 @@ export default (({ tag,
|
||||
<>
|
||||
{(linkFlg && withWiki) && (
|
||||
<span className="mr-1">
|
||||
{havingWiki
|
||||
{(tag.materialId != null || tag.hasWiki)
|
||||
? (
|
||||
<PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</PrefetchLink>)
|
||||
tag.materialId == null
|
||||
? (
|
||||
<PrefetchLink
|
||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
<PrefetchLink
|
||||
to={`/materials/${ tag.materialId }`}
|
||||
className={linkClass}>
|
||||
?
|
||||
</PrefetchLink>))
|
||||
: (
|
||||
<PrefetchLink to={`/wiki/${ 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 } Wiki が存在しません.`}>
|
||||
!
|
||||
</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]
|
||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||
title={`${ tag.name } Wiki が存在しません.`}>
|
||||
!
|
||||
</PrefetchLink>))}
|
||||
</span>)}
|
||||
{nestLevel > 0 && (
|
||||
<span
|
||||
@@ -108,19 +94,12 @@ export default (({ tag,
|
||||
</>)}
|
||||
{linkFlg
|
||||
? (
|
||||
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 () }`}
|
||||
className={linkClass}
|
||||
{...props}>
|
||||
{tag.name}
|
||||
</PrefetchLink>)
|
||||
<PrefetchLink
|
||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||
className={linkClass}
|
||||
{...props}>
|
||||
{tag.name}
|
||||
</PrefetchLink>)
|
||||
: (
|
||||
<span className={spanClass}
|
||||
{...props}>
|
||||
|
||||
@@ -65,8 +65,10 @@ export default (({ posts, onClick }: Props) => {
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
<li key={tag.id} className="mb-1">
|
||||
<motion.div layoutId={`tag-${ tag.id }`}>
|
||||
<TagLink tag={tag} prefetch onClick={onClick}/>
|
||||
<motion.div
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`tag-${ tag.id }`}>
|
||||
<TagLink tag={tag} onClick={onClick}/>
|
||||
</motion.div>
|
||||
</li>))) : [])}
|
||||
</ul>
|
||||
|
||||
@@ -14,11 +14,65 @@ import { fetchWikiPage } from '@/lib/wiki'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { Menu, User } from '@/types'
|
||||
import type { Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||
|
||||
type Props = { user: User | null }
|
||||
|
||||
|
||||
export const menuOutline = ({ tag, wikiId, user, pathName }: {
|
||||
tag?: Tag | null
|
||||
wikiId: number | null
|
||||
user: User | null,
|
||||
pathName: string }): Menu => {
|
||||
const postCount = tag?.postCount ?? 0
|
||||
|
||||
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (pathName) && wikiId)
|
||||
const wikiTitle = pathName.split ('/')[2] ?? ''
|
||||
|
||||
return [
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '検索', to: '/posts/search' },
|
||||
{ name: '追加', to: '/posts/new' },
|
||||
{ name: '履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'マスタ', to: '/tags' },
|
||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||
{ name: '素材', to: '/materials', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/materials' },
|
||||
{ name: '検索', to: '/materials/search', visible: false },
|
||||
{ name: '追加', to: '/materials/new' },
|
||||
{ name: '履歴', to: '/materials/changes', visible: false },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
|
||||
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||
{ name: <>第 1 会場</>, to: '/theatres/1' },
|
||||
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
|
||||
{ name: <>ニジカ放送局第 1 チャンネル</>,
|
||||
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new' },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator/>, visible: wikiPageFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
||||
{ name: '法規', visible: false, subMenu: [
|
||||
{ name: '利用規約', to: '/tos' }] }]
|
||||
}
|
||||
|
||||
|
||||
export default (({ user }: Props) => {
|
||||
const location = useLocation ()
|
||||
|
||||
@@ -26,25 +80,30 @@ export default (({ user }: Props) => {
|
||||
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const measure = () => {
|
||||
const measure = (idx: number) => {
|
||||
const nav = navRef.current
|
||||
const el = itemsRef.current[activeIdx]
|
||||
if (!(nav) || !(el) || activeIdx < 0)
|
||||
return
|
||||
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
||||
|
||||
if (!(nav) || !(el))
|
||||
{
|
||||
setHL ({ left: 0, width: 0, visible: true })
|
||||
return
|
||||
}
|
||||
|
||||
const navRect = nav.getBoundingClientRect ()
|
||||
const elRect = el.getBoundingClientRect ()
|
||||
|
||||
setHl ({ left: elRect.left - navRect.left,
|
||||
setHL ({ left: elRect.left - navRect.left,
|
||||
width: elRect.width,
|
||||
visible: true })
|
||||
}
|
||||
|
||||
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||
left: 0,
|
||||
width: 0,
|
||||
visible: false })
|
||||
const [menuOpen, setMenuOpen] = useState (false)
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||
|
||||
@@ -62,42 +121,10 @@ export default (({ user }: Props) => {
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||
|
||||
const postCount = tag?.postCount ?? 0
|
||||
|
||||
const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
|
||||
const wikiTitle = location.pathname.split ('/')[2] ?? ''
|
||||
const menu: Menu = [
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '検索', to: '/posts/search' },
|
||||
{ name: '投稿追加', to: '/posts/new' },
|
||||
{ name: '履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'タグ一覧', to: '/tags', visible: true },
|
||||
{ name: '別名タグ', to: '/tags/aliases', visible: false },
|
||||
{ name: '上位タグ', to: '/tags/implications', visible: false },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] },
|
||||
// TODO: 本実装時に消す.
|
||||
// { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
|
||||
// { name: '一覧', to: '/theatres' }] },
|
||||
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new' },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator/>, visible: wikiPageFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] },
|
||||
{ name: 'ユーザ', to: '/users', subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
|
||||
|
||||
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname })
|
||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||
const activeIdx =
|
||||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
@@ -110,35 +137,31 @@ export default (({ user }: Props) => {
|
||||
const dir = dirRef.current
|
||||
|
||||
useLayoutEffect (() => {
|
||||
if (activeIdx < 0)
|
||||
return
|
||||
|
||||
const raf = requestAnimationFrame (measure)
|
||||
const onResize = () => requestAnimationFrame (measure)
|
||||
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx))
|
||||
|
||||
addEventListener ('resize', onResize)
|
||||
return () => {
|
||||
cancelAnimationFrame (raf)
|
||||
removeEventListener ('resize', onResize)
|
||||
}
|
||||
}, [activeIdx])
|
||||
})
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
return () => unsubscribe ()
|
||||
}, [])
|
||||
}, [activeIdx])
|
||||
|
||||
useEffect (() => {
|
||||
setMenuOpen (false)
|
||||
setOpenItemIdx (menu.findIndex (item => (
|
||||
location.pathname.startsWith (item.base || item.to))))
|
||||
setOpenItemIdx (activeIdx)
|
||||
}, [location])
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="px-3 flex justify-between items-center w-full min-h-[48px]
|
||||
<nav className="px-3 flex justify-between items-center w-full
|
||||
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
|
||||
<div className="flex items-center gap-2 h-full">
|
||||
<div className="flex items-center gap-2 h-12">
|
||||
<PrefetchLink
|
||||
to="/posts"
|
||||
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
|
||||
@@ -149,26 +172,48 @@ export default (({ user }: Props) => {
|
||||
ぼざクリ タグ広場
|
||||
</PrefetchLink>
|
||||
|
||||
<div ref={navRef} className="relative hidden md:flex h-full items-center">
|
||||
<div ref={navRef} className="relative hidden md:flex h-12 items-center">
|
||||
<div aria-hidden
|
||||
className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
|
||||
className={cn ('absolute inset-y-0 h-12',
|
||||
'bg-yellow-200 dark:bg-red-950',
|
||||
'transition-[transform,width] duration-200 ease-out')}
|
||||
style={{ width: hl.width,
|
||||
transform: `translate(${ hl.left }px, -50%)`,
|
||||
transform: `translateX(${ hl.left }px)`,
|
||||
opacity: hl.visible ? 1 : 0 }}/>
|
||||
|
||||
{menu.map ((item, i) => (
|
||||
<PrefetchLink
|
||||
key={i}
|
||||
to={item.to}
|
||||
ref={(el: (HTMLAnchorElement | null)) => {
|
||||
itemsRef.current[i] = el
|
||||
}}
|
||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
</PrefetchLink>))}
|
||||
{visibleMenu.map ((item, i) => (
|
||||
<motion.div
|
||||
key={item.to}
|
||||
layoutId={`menu-${ item.name }`}
|
||||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||
transition={{ opacity: { duration: .12 },
|
||||
layout: { duration: .2, ease: 'easeOut' } }}
|
||||
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }}
|
||||
onMouseEnter={() => setMoreVsbl (false)}>
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
ref={(el: (HTMLAnchorElement | null)) => {
|
||||
itemsRef.current[i] = el
|
||||
}}
|
||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
</PrefetchLink>
|
||||
</motion.div>))}
|
||||
<PrefetchLink
|
||||
to="/more"
|
||||
ref={(el: (HTMLAnchorElement | null)) => {
|
||||
itemsRef.current[visibleMenu.length] = el
|
||||
}}
|
||||
onClick={() => setMoreVsbl (false)}
|
||||
onMouseEnter={() => {
|
||||
setMoreVsbl (true)
|
||||
measure (-1)
|
||||
}}
|
||||
className={cn ('relative z-10 flex h-full items-center px-5',
|
||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||
その他 »
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -186,35 +231,115 @@ export default (({ user }: Props) => {
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
|
||||
items-center w-full min-h-[40px] overflow-hidden">
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="absolute inset-0 flex items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{(menu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
|
||||
: (
|
||||
<PrefetchLink
|
||||
key={`l-${ i }`}
|
||||
to={item.to}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</PrefetchLink>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
key="submenu-shell"
|
||||
layout
|
||||
className="relative hidden md:block overflow-hidden
|
||||
bg-yellow-200 dark:bg-red-950"
|
||||
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }}
|
||||
onMouseLeave={() => {
|
||||
if (moreVsbl)
|
||||
setMoreVsbl (false)
|
||||
}}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
onAnimationComplete={() => {
|
||||
measure (moreVsbl ? -1 : activeIdx)
|
||||
}}>
|
||||
{moreVsbl
|
||||
? (
|
||||
menu.map ((item, i) => (
|
||||
<div key={i} className="relative h-[40px]">
|
||||
<div className="absolute inset-0 flex items-center px-3">
|
||||
<motion.div
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
{...((item.visible ?? true)
|
||||
? { layoutId: `menu-${ item.name }` }
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } })}
|
||||
className="z-10 h-full flex items-center px-3 font-bold w-24">
|
||||
<h2>{item.name}</h2>
|
||||
</motion.div>
|
||||
{item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => (
|
||||
'component' in subItem
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }-${ j }`}
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
{...((visibleMenu[activeIdx]?.name
|
||||
=== item.name)
|
||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } })}>
|
||||
{subItem.component}
|
||||
</motion.div>)
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }-${ j }`}
|
||||
transition={{ duration: .2, ease: 'easeOut' }}
|
||||
{...((visibleMenu[activeIdx]?.name
|
||||
=== item.name)
|
||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
||||
: { initial: { y: -40, opacity: 0 },
|
||||
animate: { y: 0, opacity: 1 },
|
||||
exit: { y: -40, opacity: 0 } })}>
|
||||
<PrefetchLink
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||
onClick={() => setMoreVsbl (false)}
|
||||
className="h-full flex items-center px-3">
|
||||
{subItem.name}
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</div>
|
||||
</div>)))
|
||||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||
&& (
|
||||
<div className="relative h-[40px]">
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="absolute inset-0 flex items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }`}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
{item.component}
|
||||
</motion.div>)
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }`}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
target={item.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||
className="h-full flex items-center px-3">
|
||||
{item.name}
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>))}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{menuOpen && (
|
||||
@@ -231,7 +356,7 @@ export default (({ user }: Props) => {
|
||||
exit="closed"
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
<Separator/>
|
||||
{menu.map ((item, i) => (
|
||||
{visibleMenu.map ((item, i) => (
|
||||
<Fragment key={i}>
|
||||
<PrefetchLink
|
||||
to={i === openItemIdx ? item.to : '#'}
|
||||
@@ -275,12 +400,25 @@ export default (({ user }: Props) => {
|
||||
<PrefetchLink
|
||||
key={`sp-l-${ i }-${ j }`}
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="w-full min-h-[36px] flex items-center pl-12">
|
||||
{subItem.name}
|
||||
</PrefetchLink>)))}
|
||||
</motion.div>)}
|
||||
</AnimatePresence>
|
||||
</Fragment>))}
|
||||
<PrefetchLink
|
||||
to="/more"
|
||||
ref={(el: (HTMLAnchorElement | null)) => {
|
||||
itemsRef.current[visibleMenu.length] = el
|
||||
}}
|
||||
className={cn ('w-full min-h-[40px] flex items-center pl-8',
|
||||
((openItemIdx < 0)
|
||||
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}>
|
||||
その他 »
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
</motion.div>)}
|
||||
|
||||
@@ -4,8 +4,6 @@ import ReactMarkdown from 'react-markdown'
|
||||
import remarkGFM from 'remark-gfm'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import SubsectionTitle from '@/components/common/SubsectionTitle'
|
||||
import { wikiKeys } from '@/lib/queryKeys'
|
||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||
import { fetchWikiPages } from '@/lib/wiki'
|
||||
@@ -16,19 +14,15 @@ import type { Components } from 'react-markdown'
|
||||
type Props = { title: string
|
||||
body?: string }
|
||||
|
||||
const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>,
|
||||
h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>,
|
||||
ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
|
||||
ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
|
||||
a: (({ href, children }) => (
|
||||
['/', '.'].some (e => href?.startsWith (e))
|
||||
? <PrefetchLink to={href!}>{children}</PrefetchLink>
|
||||
: (
|
||||
<a href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>))) } as const satisfies Components
|
||||
const mdComponents = { a: (({ href, children }) => (
|
||||
['/', '.'].some (e => href?.startsWith (e))
|
||||
? <PrefetchLink to={href!}>{children}</PrefetchLink>
|
||||
: (
|
||||
<a href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{children}
|
||||
</a>))) } as const satisfies Components
|
||||
|
||||
|
||||
export default (({ title, body }: Props) => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Props = { children: React.ReactNode }
|
||||
import type { ComponentPropsWithoutRef, FC } from 'react'
|
||||
|
||||
type Props = ComponentPropsWithoutRef<'h2'>
|
||||
|
||||
|
||||
export default ({ children }: Props) => (
|
||||
<h2 className="text-xl my-4">
|
||||
export default (({ children, className, ...rest }: Props) => (
|
||||
<h2 {...rest} className={cn ('text-xl my-4', className)}>
|
||||
{children}
|
||||
</h2>)
|
||||
</h2>)) satisfies FC<Props>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
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>
|
||||
@@ -1,3 +1,5 @@
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
@@ -8,6 +10,9 @@ type Props = {
|
||||
|
||||
|
||||
export default (({ children, className }: Props) => (
|
||||
<main className={cn ('flex-1 overflow-y-auto p-4', className)}>
|
||||
<motion.main
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className={cn ('flex-1 overflow-y-auto p-4', className)}
|
||||
layout="position">
|
||||
{children}
|
||||
</main>)) satisfies FC<Props>
|
||||
</motion.main>)) satisfies FC<Props>
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
type Props = { children: React.ReactNode }
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = { children: ReactNode }
|
||||
|
||||
|
||||
export default ({ children }: Props) => (
|
||||
<div className="p-4 w-full md:w-64 md:h-full">
|
||||
export default (({ children }: Props) => (
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||
className="p-4 w-full md:w-64 md:h-full md:overflow-y-auto sidebar">
|
||||
<Helmet>
|
||||
<style>
|
||||
{`
|
||||
.sidebar
|
||||
{
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
.sidebar > *
|
||||
{
|
||||
direction: ltr;
|
||||
}`}
|
||||
</style>
|
||||
</Helmet>
|
||||
|
||||
{children}
|
||||
</div>)
|
||||
</motion.div>)) satisfies FC<Props>
|
||||
|
||||
@@ -46,10 +46,12 @@ a
|
||||
body
|
||||
{
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root
|
||||
{
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
h1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiDelete, apiGet, apiPost } from '@/lib/api'
|
||||
|
||||
import type { FetchPostsParams, Post, PostTagChange } from '@/types'
|
||||
import type { FetchPostsParams, Post, PostVersion } from '@/types'
|
||||
|
||||
|
||||
export const fetchPosts = async (
|
||||
@@ -29,17 +29,17 @@ export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/po
|
||||
|
||||
|
||||
export const fetchPostChanges = async (
|
||||
{ id, tag, page, limit }: {
|
||||
id?: string
|
||||
{ post, tag, page, limit }: {
|
||||
post?: string
|
||||
tag?: string
|
||||
page: number
|
||||
limit: number },
|
||||
): Promise<{
|
||||
changes: PostTagChange[]
|
||||
versions: PostVersion[]
|
||||
count: number }> =>
|
||||
await apiGet ('/posts/changes', { params: { ...(id && { id }),
|
||||
...(tag && { tag }),
|
||||
page, limit } })
|
||||
await apiGet ('/posts/versions', { params: { ...(post && { post }),
|
||||
...(tag && { tag }),
|
||||
page, limit } })
|
||||
|
||||
|
||||
export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => {
|
||||
|
||||
@@ -5,7 +5,7 @@ export const postsKeys = {
|
||||
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
|
||||
show: (id: string) => ['posts', id] as const,
|
||||
related: (id: string) => ['related', id] as const,
|
||||
changes: (p: { id?: string; tag?: string; page: number; limit: number }) =>
|
||||
changes: (p: { post?: string; tag?: string; page: number; limit: number }) =>
|
||||
['posts', 'changes', p] as const }
|
||||
|
||||
export const tagsKeys = {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { MDXComponents } from 'mdx/types'
|
||||
|
||||
|
||||
export const useMDXComponents = (): MDXComponents => ({ })
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { menuOutline } from '@/components/TopNav'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { User } from '@/types'
|
||||
|
||||
|
||||
export default (() => {
|
||||
const menu = menuOutline (
|
||||
{ tag: null, wikiId: null, user: { } as User, pathName: location.pathname })
|
||||
|
||||
return (
|
||||
<MainArea className="md:flex">
|
||||
<Helmet>
|
||||
<title>{`メニュー | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
{[...Array (Math.ceil (menu.length / 4)).keys ()].map (i => (
|
||||
<div key={i} className="flex-1 mx-16">
|
||||
{menu.slice (4 * i, 4 * (i + 1)).map ((item, j) => (
|
||||
<section key={j}>
|
||||
<SectionTitle className="font-bold">{item.name}</SectionTitle>
|
||||
<ul>
|
||||
{item.subMenu
|
||||
.filter (subItem => (subItem.visible ?? true))
|
||||
.map ((subItem, k) => ('name' in subItem && (
|
||||
<li key={k}>
|
||||
<PrefetchLink
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}>
|
||||
{subItem.name}
|
||||
</PrefetchLink>
|
||||
</li>)))}
|
||||
</ul>
|
||||
</section>))}
|
||||
</div>))}
|
||||
</MainArea>)
|
||||
}) satisfies FC
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { dateString } from '@/lib/utils'
|
||||
|
||||
export const lastUpdatedAt = dateString ('2026-04-12', 'hour')
|
||||
|
||||
|
||||
<MainArea>
|
||||
<Helmet>
|
||||
<title>{`利用規約 | ${ SITE_TITLE }`}</title>
|
||||
</Helmet>
|
||||
|
||||
<article className="prose dark:prose-invert mx-auto p-4">
|
||||
# 利用規約
|
||||
|
||||
最終更新日: {lastUpdatedAt}
|
||||
|
||||
この利用規約(以下「本規約」)は、ぼざクリ タグ広場(以下「本サービス」)の利用条件を定めるものです。利用者は、本サービスを利用した時点で、本規約に同意したものとみなされます。
|
||||
|
||||
## 第 1 条 本サービスの位置づけ
|
||||
|
||||
1. 本サービスは、タグ・Wiki・外部リンクの整理を中心とする知識共有基盤です。
|
||||
2. 本サービスの中心価値は、コンテンツそのものの再配布ではなく、タグを軸にした整理、検索、再発見、および周辺知識の蓄積にあります。
|
||||
3. 本サービスは、運営上の必要に応じて、機能、公開範囲、名称、URL、表示内容その他の仕様を変更することがあります。
|
||||
|
||||
## 第 2 条 公開方針と利用者区分
|
||||
|
||||
1. 本サービスは、初回一般公開時点では、**誰でも閲覧できる一方で、投稿・編輯は申請制** とします。
|
||||
2. 初回一般公開時点では、通常の農奴は閲覧のみを行えます。
|
||||
3. 投稿、タグ編輯、Wiki 編輯その他の耕作行為は、運営が承認した利用者(以下「耕作員」)に限って認めます。
|
||||
4. 独裁者は、耕作員に加えて、差戻、削除、利用制限、その他の管理操作を行えます。
|
||||
5. 運営は、履歴管理、差戻、BAN 運用、監査導線その他の運営装備がじゅうぶんに整ったと判断した場合、農奴に一部の編輯権限を開放することがあります。
|
||||
6. 利用者区分、権限範囲、申請条件、承認基準、承認後の取扱いは、運営が必要に応じて定め、変更できます。
|
||||
|
||||
## 第 3 条 利用開始と引継ぎコード
|
||||
|
||||
1. 本サービスでは、一般的な Id. / パスワード方式ではなく、運営が別途定める認証情報または引継ぎコードを用いる場合があります。
|
||||
2. 利用者は、自身に割り当てられた引継ぎコード、認証情報、端末上の保存情報を自己の責任で管理するものとします。
|
||||
3. 利用者は、自己の引継ぎコードまたは認証情報を第 3 者に譲渡、貸与、共有、漏洩してはなりません。
|
||||
4. 引継ぎコードの漏洩、第 3 者利用、紛失、盗用その他の事故によって利用者または第 3 者に生じた損害について、運営は責任を負いません。
|
||||
5. 運営は、本人確認、濫用対策、監査対応または保守のため、利用情報とアクセス元情報を関聯づけて扱うことがあります。
|
||||
|
||||
## 第 4 条 申請制編輯の基本ルール
|
||||
|
||||
1. 耕作員は、タグ整理基盤の品質維持を最優先し、個人的な所有主張ではなく、検索性、再利用性、可読性、整合性を重視して編輯しなければなりません。
|
||||
2. 耕作員は、主観的な好悪、内輪ネタ、報復、私怨、対立誘導のためにタグや Wiki を操作してはなりません。
|
||||
3. 耕作員は、誤りの修正、体系の整理、リンクの保守、知識の補足を目的として編輯を行うものとします。
|
||||
4. 運営は、申請内容、過去の行動、編輯品質、聯絡可能性、運営負荷その他の事情を考慮して、承認、保留、拒否、取消を行えます。
|
||||
5. 耕作員資格は権利ではなく、運営が本サービスの維持のために付与する可撤回の権限です。
|
||||
|
||||
## 第 5 条 禁止事項
|
||||
|
||||
利用者は、以下の行為をしてはなりません。
|
||||
|
||||
1. 法令または公序良俗に違反する行為。
|
||||
2. 犯罪を助長し、またはこれに結びつく行為。
|
||||
3. 著作権、著作者人格権、商標権、肖像権、パブリシティ権、プライバシー権その他第 3 者の権利を侵害する行為。
|
||||
4. 無断転載、違法アップロード、違法複製物、海賊版、権限のない転載先への誘導、またはそれらを正当化、拡散、補助する行為。
|
||||
5. 実在人物に関する名誉毀損、侮辱、差別、脅迫、晒し、つきまとい、嫌がらせ、私刑の扇動その他の加害行為。
|
||||
6. 個人情報、非公開情報、秘匿されるべき情報を本人の承諾なく掲載、送信、共有、推測可能な形で開示する行為。
|
||||
7. 虚偽の情報、誤解を招く情報、出典を偽装した情報、意図的なミスリード、荒らし目的のタグづけ、関係のないタグの大量付与、分類妨碍、検索妨碍その他の品質破壊行為。
|
||||
8. マルウェア、フィッシング、詐欺、誘導広告、悪質なリダイレクト、危険な外部リンクその他利用者または運営に危害を与える行為。
|
||||
9. 本サービスの趣旨に照らして不相当な政治的扇動、宗教勧誘、商業宣伝、連鎖的勧誘、スパム、同一内容の反復送信。
|
||||
10. 未成年の安全に反する行為、児童性的搾取、違法または著しく不適切な性的表現、過度に露骨な性表現や残虐表現を、一般公開導線に無警告で流し込む行為。
|
||||
11. 運営、他の利用者、外部サービスまたは第 3 者に著しい負担、不利益、混乱を生じさせる行為。
|
||||
12. 前各号のいずれかを試みる行為、教唆する行為、容易にする行為。
|
||||
13. その他、運営が本サービスの目的または安全な運営に照らして不適切と判断する行為。
|
||||
|
||||
## 第 6 条 投稿、タグ、Wiki 等の取扱い
|
||||
|
||||
1. 利用者は、自らが投稿、編輯、登録、送信または変更する情報について、必要な権利を有し、または適法に利用できる状態でなければなりません。
|
||||
2. 利用者は、自らが行った投稿、タグづけ、Wiki 編輯、説明文、コメント、関聯づけその他の行為について責任を負います。
|
||||
3. 利用者は、運営に対し、本サービスの運営、表示、複製、保存、配信、整形、引用、履歴表示、差戻、バックアップ、障碍対応および弘報のために必要な範囲で、当該利用者生成情報を無償で利用する非独占的な権利を許諾するものとします。
|
||||
4. 前項の許諾は、本サービスの運営上必要な範囲に限られ、利用者の権利帰属自体を運営へ移転するものではありません。
|
||||
5. 運営は、分類整合性、表記統一、誤記修正、別名統合、差戻その他の理由により、投稿、タグ、Wiki その他の内容を編輯、非表示化、削除、統合、分割または凍結できます。
|
||||
|
||||
## 第 7 条 外部リンクと埋め込み
|
||||
|
||||
1. 本サービスは、外部サイトへのリンク、外部コンテンツの埋め込みまたはそれらに関するメタデータを表示する場合があります。
|
||||
2. 外部リンク先または埋め込み先の権利、利用条件、公開範囲、削除方針、広告、追跡、Cookie その他の取扱いは、当該外部サービスの定めに従います。
|
||||
3. 運営は、外部リンク先の適法性、安全性、継続性、正確性、品質、可用性、または内容の完全性を保証しません。
|
||||
4. 外部権利者からの申立て、運営判断、法令対応または安全性確保のため、運営は外部リンク、埋め込み、サムネイル、説明文その他の表示を制限、差替え、非表示または削除できます。
|
||||
|
||||
## 第 8 条 履歴、差戻、削除
|
||||
|
||||
1. 本サービスでは、保守、監査、荒らし対策、説明責任その他の目的で、投稿、タグ、Wiki その他の変更履歴を保持し、表示し、または内部的に参照することがあります。
|
||||
2. 利用者は、一度行った編輯が、後に差戻、修正、非表示化または削除されることがあることをあらかじめ承諾するものとします。
|
||||
3. 利用者が削除を希望した場合でも、法令上、保守上、監査上、紛争対応上またはバックアップ上の必要により、直ちに完全消去できないことがあります。
|
||||
4. 運営は、本サービス全体の健全性を維持するため、説明の有無を問わず、履歴の表示範囲、保存期間、差戻方針、削除方針を定め、変更できます。
|
||||
|
||||
## 第 9 条 利用制限、資格取消、BAN
|
||||
|
||||
1. 運営は、利用者が次のいずれかに該当すると判断した場合、事前の通知なく、または通知後に、投稿・編輯の制限、耕作員資格の取消、コンテンツの非表示または削除、引継ぎコードの失効、ユーザ BAN、IP BAN その他必要な措置を行えます。
|
||||
- 本規約に違反した場合
|
||||
- 本サービスの趣旨に反する運用妨碍、荒らし、品質破壊行為を行った場合
|
||||
- 運営からの確認、修正要請、停止要請に合理的理由なく応じない場合
|
||||
- 登録情報、申請内容または説明に虚偽がある場合
|
||||
- 安全性、法令順守、運営継続の観点から措置が必要と判断された場合
|
||||
2. 運営は、前項の措置について、その理由、基準、証拠または内部判断過程を常に開示する義務を負いません。
|
||||
3. 利用制限または資格取消後も、運営は、必要に応じて履歴、ログ、申請記録、通報記録その他のデータを保持できます。
|
||||
|
||||
## 第 10 条 未成年の利用
|
||||
|
||||
1. 運営は、未成年の安全確保の観点から、年齢に応じた表示制限、導線制御、非表示化、削除、申請拒否その他の措置を行えます。
|
||||
2. 利用者は、未成年が閲覧しうる一般公開面において、未成年に不適切な内容を無警告で流し込まないものとします。
|
||||
|
||||
## 第 11 条 お問い合わせ、通報、御意見番
|
||||
|
||||
1. 利用者は、本サービスが別途案内する問い合わせ、通報または御意見板の導線を通じて、バグ報告、問題報告、削除要請その他の聯絡を行えます。
|
||||
2. 運営は、すべての問い合わせに回答する義務を負わず、回答期限、対応結果または対応方法を保証しません。
|
||||
|
||||
## 第 12 条 免責
|
||||
|
||||
1. 運営は、本サービスについて、特定目的適合性、完全性、正確性、継続性、安全性、無瑕疵性、または利用者の期待への適合を保証しません。
|
||||
2. 運営は、外部リンク先、外部埋め込み先、第 3 者投稿、利用者同士の紛争、通信障碍、データ消失、誤分類、誤リンク、誤記、差戻、機能停止または仕様変更によって生じた損害について、責任を負いません。
|
||||
3. 本サービスは、予告なく停止、終了、変更または縮小されることがあります。
|
||||
|
||||
## 第 13 条 規約の変更
|
||||
|
||||
1. 運営は、法令改正、機能追加、運用方針の変更、安全対策、表現調整その他の理由により、本規約を変更できます。
|
||||
2. 変更後の本規約は、本サービス上に掲載された時点または運営が別途定める時点から効力を生じます。
|
||||
3. 変更後に利用を継続した利用者は、変更後の本規約に同意したものとみなされます。
|
||||
|
||||
## 第 14 条 準拠法および管轄
|
||||
|
||||
1. 本規約および本サービスの利用には、日本法を準拠法とします。
|
||||
2. 本規約または本サービスに関して生じた一切の紛争については、運営の所在地を管轄する裁判所を第 1 審の専属的合意管轄裁判所とします。ただし、法令に別段の定めがある場合はこの限りではありません。
|
||||
|
||||
## 附則
|
||||
|
||||
本規約は、{lastUpdatedAt} から適用します。
|
||||
</article>
|
||||
</MainArea>
|
||||
@@ -0,0 +1,12 @@
|
||||
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 overflow-y-auto md:overflow-y-hidden">
|
||||
<MaterialSidebar/>
|
||||
<Outlet/>
|
||||
</div>)) satisfies FC
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
@@ -0,0 +1,166 @@
|
||||
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
|
||||
@@ -0,0 +1,124 @@
|
||||
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
|
||||
@@ -0,0 +1,49 @@
|
||||
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,10 +93,10 @@ export default (({ user }: Props) => {
|
||||
: 'bg-gray-500 hover:bg-gray-600')
|
||||
|
||||
return (
|
||||
<div className="md:flex md:flex-1">
|
||||
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<Helmet>
|
||||
{(post?.thumbnail || post?.thumbnailBase) && (
|
||||
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
|
||||
<meta name="thumbnail" content={post.thumbnail! || post.thumbnailBase!}/>)}
|
||||
{post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>}
|
||||
</Helmet>
|
||||
|
||||
@@ -116,7 +116,7 @@ export default (({ user }: Props) => {
|
||||
initial={{ opacity: 1 }}
|
||||
animate={{ opacity: 0 }}
|
||||
transition={{ duration: .2, ease: 'easeOut' }}>
|
||||
<img src={post.thumbnail || post.thumbnailBase}
|
||||
<img src={post.thumbnail || post.thumbnailBase || undefined}
|
||||
alt={post.title || post.url}
|
||||
title={post.title || post.url || undefined}
|
||||
className="object-cover w-full h-full"/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
@@ -9,15 +9,30 @@ import PrefetchLink from '@/components/PrefetchLink'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { toast } from '@/components/ui/use-toast'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiPut } from '@/lib/api'
|
||||
import { fetchPostChanges } from '@/lib/posts'
|
||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { fetchTag } from '@/lib/tags'
|
||||
import { cn, dateString } from '@/lib/utils'
|
||||
import { cn, dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
|
||||
const renderDiff = (diff: { current: string | null; prev: string | null }) => (
|
||||
<>
|
||||
{(diff.prev && diff.prev !== diff.current) && (
|
||||
<>
|
||||
<del className="text-red-600 dark:text-red-400">
|
||||
{diff.prev}
|
||||
</del>
|
||||
{diff.current && <br/>}
|
||||
</>)}
|
||||
{diff.current}
|
||||
</>)
|
||||
|
||||
|
||||
export default (() => {
|
||||
const location = useLocation ()
|
||||
const query = new URLSearchParams (location.search)
|
||||
@@ -36,15 +51,17 @@ export default (() => {
|
||||
: { data: null }
|
||||
|
||||
const { data, isLoading: loading } = useQuery ({
|
||||
queryKey: postsKeys.changes ({ ...(id && { id }),
|
||||
queryKey: postsKeys.changes ({ ...(id && { post: id }),
|
||||
...(tagId && { tag: tagId }),
|
||||
page, limit }),
|
||||
queryFn: () => fetchPostChanges ({ ...(id && { id }),
|
||||
queryFn: () => fetchPostChanges ({ ...(id && { post: id }),
|
||||
...(tagId && { tag: tagId }),
|
||||
page, limit }) })
|
||||
const changes = data?.changes ?? []
|
||||
const changes = data?.versions ?? []
|
||||
const totalPages = data ? Math.ceil (data.count / limit) : 0
|
||||
|
||||
const qc = useQueryClient ()
|
||||
|
||||
useEffect (() => {
|
||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||
}, [location.search])
|
||||
@@ -65,76 +82,171 @@ export default (() => {
|
||||
|
||||
{loading ? 'Loading...' : (
|
||||
<>
|
||||
<table className="table-auto w-full border-collapse">
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">投稿</th>
|
||||
<th className="p-2 text-left">変更</th>
|
||||
<th className="p-2 text-left">日時</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map ((change, i) => {
|
||||
const withPost = i === 0 || change.post.id !== changes[i - 1].post.id
|
||||
if (withPost)
|
||||
{
|
||||
rowsCnt = 1
|
||||
for (let j = i + 1;
|
||||
(j < changes.length
|
||||
&& change.post.id === changes[j].post.id);
|
||||
++j)
|
||||
++rowsCnt
|
||||
}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1200px] table-fixed border-collapse">
|
||||
<colgroup>
|
||||
{/* 投稿 */}
|
||||
<col className="w-64"/>
|
||||
{/* 版 */}
|
||||
<col className="w-40"/>
|
||||
{/* タイトル */}
|
||||
<col className="w-96"/>
|
||||
{/* URL */}
|
||||
<col className="w-96"/>
|
||||
{/* タグ */}
|
||||
<col className="w-[48rem]"/>
|
||||
{/* オリジナルの投稿日時 */}
|
||||
<col className="w-96"/>
|
||||
{/* 更新日時 */}
|
||||
<col className="w-64"/>
|
||||
{/* (差戻ボタン) */}
|
||||
<col className="w-20"/>
|
||||
</colgroup>
|
||||
|
||||
let layoutId: string | undefined = `page-${ change.post.id }`
|
||||
if (layoutIds.includes (layoutId))
|
||||
layoutId = undefined
|
||||
else
|
||||
layoutIds.push (layoutId)
|
||||
<thead className="border-b-2 border-black dark:border-white">
|
||||
<tr>
|
||||
<th className="p-2 text-left">投稿</th>
|
||||
<th className="p-2 text-left">版</th>
|
||||
<th className="p-2 text-left">タイトル</th>
|
||||
<th className="p-2 text-left">URL</th>
|
||||
<th className="p-2 text-left">タグ</th>
|
||||
<th className="p-2 text-left">オリジナルの投稿日時</th>
|
||||
<th className="p-2 text-left">更新日時</th>
|
||||
<th className="p-2"/>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
return (
|
||||
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag?.id }`}
|
||||
className={cn ('even:bg-gray-100 dark:even:bg-gray-700',
|
||||
withPost && 'border-t')}>
|
||||
{withPost && (
|
||||
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
|
||||
rowSpan={rowsCnt}>
|
||||
<PrefetchLink to={`/posts/${ change.post.id }`}>
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ type: 'spring',
|
||||
stiffness: 500,
|
||||
damping: 40,
|
||||
mass: .5 }}>
|
||||
<img src={change.post.thumbnail
|
||||
|| change.post.thumbnailBase
|
||||
|| undefined}
|
||||
alt={change.post.title || change.post.url}
|
||||
title={change.post.title || change.post.url || undefined}
|
||||
className="w-40"/>
|
||||
</motion.div>
|
||||
</PrefetchLink>
|
||||
</td>)}
|
||||
<td className="p-2">
|
||||
{change.tag
|
||||
? <TagLink tag={change.tag} withWiki={false} withCount={false}/>
|
||||
: '(マスタ削除済のタグ) '}
|
||||
{`を${ change.changeType === 'add' ? '記載' : '消除' }`}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.user
|
||||
? (
|
||||
<PrefetchLink to={`/users/${ change.user.id }`}>
|
||||
{change.user.name}
|
||||
</PrefetchLink>)
|
||||
: 'bot 操作'}
|
||||
<br/>
|
||||
{dateString (change.timestamp)}
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<tbody>
|
||||
{changes.map ((change, i) => {
|
||||
const withPost = i === 0 || change.postId !== changes[i - 1].postId
|
||||
if (withPost)
|
||||
{
|
||||
rowsCnt = 1
|
||||
for (let j = i + 1;
|
||||
(j < changes.length
|
||||
&& change.postId === changes[j].postId);
|
||||
++j)
|
||||
++rowsCnt
|
||||
}
|
||||
|
||||
let layoutId: string | undefined = `page-${ change.postId }`
|
||||
if (layoutIds.includes (layoutId))
|
||||
layoutId = undefined
|
||||
else
|
||||
layoutIds.push (layoutId)
|
||||
|
||||
return (
|
||||
<tr key={`${ change.postId }.${ change.versionNo }`}
|
||||
className={cn ('even:bg-gray-100 dark:even:bg-gray-700',
|
||||
withPost && 'border-t')}>
|
||||
{withPost && (
|
||||
<td className="align-top p-2 bg-white dark:bg-[#242424] border-r"
|
||||
rowSpan={rowsCnt}>
|
||||
<PrefetchLink to={`/posts/${ change.postId }`}>
|
||||
<motion.div
|
||||
layoutId={layoutId}
|
||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}>
|
||||
<img src={change.thumbnail.current
|
||||
|| change.thumbnailBase.current
|
||||
|| undefined}
|
||||
alt={change.title.current || change.url.current}
|
||||
title={change.title.current || change.url.current || undefined}
|
||||
className="w-40"/>
|
||||
</motion.div>
|
||||
</PrefetchLink>
|
||||
</td>)}
|
||||
<td className="p-2">{change.postId}.{change.versionNo}</td>
|
||||
<td className="p-2 break-all">{renderDiff (change.title)}</td>
|
||||
<td className="p-2 break-all">{renderDiff (change.url)}</td>
|
||||
<td className="p-2">
|
||||
{change.tags.map ((tag, i) => (
|
||||
tag.type === 'added'
|
||||
? (
|
||||
<ins
|
||||
key={i}
|
||||
className="mr-2 text-green-600 dark:text-green-400">
|
||||
{tag.name}
|
||||
</ins>)
|
||||
: (
|
||||
tag.type === 'removed'
|
||||
? (
|
||||
<del
|
||||
key={i}
|
||||
className="mr-2 text-red-600 dark:text-red-400">
|
||||
{tag.name}
|
||||
</del>)
|
||||
: (
|
||||
<span key={i} className="mr-2">
|
||||
{tag.name}
|
||||
</span>))))}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.versionNo === 1
|
||||
? originalCreatedAtString (change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current)
|
||||
: renderDiff ({
|
||||
current: originalCreatedAtString (
|
||||
change.originalCreatedFrom.current,
|
||||
change.originalCreatedBefore.current),
|
||||
prev: originalCreatedAtString (
|
||||
change.originalCreatedFrom.prev,
|
||||
change.originalCreatedBefore.prev) })}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{change.createdByUser
|
||||
? (
|
||||
<PrefetchLink to={`/users/${ change.createdByUser.id }`}>
|
||||
{change.createdByUser.name
|
||||
|| `名もなきニジラー(#${ change.createdByUser.id })`}
|
||||
</PrefetchLink>)
|
||||
: 'bot 操作'}
|
||||
<br/>
|
||||
{dateString (change.createdAt)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<a
|
||||
href="#"
|
||||
onClick={async e => {
|
||||
e.preventDefault ()
|
||||
|
||||
if (!(confirm (
|
||||
`『${ change.title.current
|
||||
|| change.url.current }』を版 ${
|
||||
change.versionNo } に差戻します.\nよろしいですか?`)))
|
||||
return
|
||||
|
||||
try
|
||||
{
|
||||
await apiPut (
|
||||
`/posts/${ change.postId }`,
|
||||
{ title: change.title.current,
|
||||
tags: change.tags
|
||||
.filter (t => t.type !== 'removed')
|
||||
.map (t => t.name)
|
||||
.filter (t => t.slice (0, 5) !== 'nico:')
|
||||
.join (' '),
|
||||
original_created_from:
|
||||
change.originalCreatedFrom.current,
|
||||
original_created_before:
|
||||
change.originalCreatedBefore.current })
|
||||
|
||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||
toast ({ description: '差戻しました.' })
|
||||
}
|
||||
catch
|
||||
{
|
||||
toast ({ description: '差戻に失敗……' })
|
||||
}
|
||||
}}>
|
||||
復元
|
||||
</a>
|
||||
</td>
|
||||
</tr>)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination page={page} totalPages={totalPages}/>
|
||||
</>)}
|
||||
|
||||
@@ -69,7 +69,9 @@ export default (() => {
|
||||
}, [location.search])
|
||||
|
||||
return (
|
||||
<div className="md:flex md:flex-1" ref={containerRef}>
|
||||
<div
|
||||
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
|
||||
ref={containerRef}>
|
||||
<Helmet>
|
||||
<title>
|
||||
{tags.length
|
||||
|
||||
@@ -7,24 +7,22 @@ import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import SortHeader from '@/components/SortHeader'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import Label from '@/components/common/Label'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import Pagination from '@/components/common/Pagination'
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { fetchPosts } from '@/lib/posts'
|
||||
import { postsKeys } from '@/lib/queryKeys'
|
||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
|
||||
import type { FC, FormEvent } from 'react'
|
||||
|
||||
import type { FetchPostsOrder,
|
||||
FetchPostsOrderField,
|
||||
FetchPostsParams,
|
||||
Tag } from '@/types'
|
||||
FetchPostsParams } from '@/types'
|
||||
|
||||
|
||||
const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
|
||||
@@ -57,14 +55,11 @@ export default (() => {
|
||||
const qUpdatedTo = query.get ('updated_to') ?? ''
|
||||
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [createdFrom, setCreatedFrom] = useState<string | null> (null)
|
||||
const [createdTo, setCreatedTo] = useState<string | null> (null)
|
||||
const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
|
||||
const [originalCreatedFrom, setOriginalCreatedFrom] = 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 [title, setTitle] = useState ('')
|
||||
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
|
||||
@@ -103,58 +98,6 @@ export default (() => {
|
||||
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
|
||||
}, [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 qs = new URLSearchParams ()
|
||||
setIf (qs, 'tags', tagsStr)
|
||||
@@ -172,15 +115,6 @@ export default (() => {
|
||||
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) => {
|
||||
e.preventDefault ()
|
||||
search ()
|
||||
@@ -223,21 +157,11 @@ export default (() => {
|
||||
</div>
|
||||
|
||||
{/* タグ */}
|
||||
<div className="relative">
|
||||
<div>
|
||||
<Label>タグ</Label>
|
||||
<input
|
||||
type="text"
|
||||
<TagInput
|
||||
value={tagsStr}
|
||||
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}/>
|
||||
setValue={setTagsStr}/>
|
||||
<fieldset className="w-full my-2">
|
||||
<label>検索区分:</label>
|
||||
<label className="mx-2">
|
||||
@@ -365,7 +289,7 @@ export default (() => {
|
||||
{results.map (row => (
|
||||
<tr key={row.id} className="even:bg-gray-100 dark:even:bg-gray-700">
|
||||
<td className="p-2">
|
||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
|
||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
||||
<motion.div
|
||||
layoutId={`page-${ row.id }`}
|
||||
transition={{ type: 'spring',
|
||||
@@ -380,7 +304,7 @@ export default (() => {
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
<td className="p-2 truncate">
|
||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title}>
|
||||
<PrefetchLink to={`/posts/${ row.id }`} title={row.title || undefined}>
|
||||
{row.title}
|
||||
</PrefetchLink>
|
||||
</td>
|
||||
|
||||
@@ -2,85 +2,225 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import ErrorScreen from '@/components/ErrorScreen'
|
||||
import PostEmbed from '@/components/PostEmbed'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { apiGet, apiPatch, apiPut } from '@/lib/api'
|
||||
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api'
|
||||
import { fetchPost } from '@/lib/posts'
|
||||
import { dateString } from '@/lib/utils'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types'
|
||||
import type { NiconicoMetadata,
|
||||
NiconicoViewerHandle,
|
||||
Post,
|
||||
Theatre,
|
||||
TheatreComment } from '@/types'
|
||||
|
||||
type TheatreInfo = {
|
||||
hostFlg: boolean
|
||||
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 (() => {
|
||||
const { id } = useParams ()
|
||||
|
||||
const commentsRef = useRef<HTMLDivElement> (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 [sending, setSending] = useState (false)
|
||||
const [status, setStatus] = useState (200)
|
||||
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
||||
const [theatreInfo, setTheatreInfo] =
|
||||
useState<TheatreInfo> ({ hostFlg: false, postId: null, postStartedAt: null })
|
||||
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||||
const [post, setPost] = useState<Post | null> (null)
|
||||
const [videoLength, setVideoLength] = useState (9_999_999_999)
|
||||
const [videoLength, setVideoLength] = useState (0)
|
||||
|
||||
useEffect (() => {
|
||||
theatreInfoRef.current = theatreInfo
|
||||
}, [theatreInfo])
|
||||
|
||||
useEffect (() => {
|
||||
videoLengthRef.current = videoLength
|
||||
}, [videoLength])
|
||||
|
||||
useEffect (() => {
|
||||
lastCommentNoRef.current = comments[0]?.no ?? 0
|
||||
}, [comments])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(id))
|
||||
return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
setComments ([])
|
||||
setTheatre (null)
|
||||
setPost (null)
|
||||
setTheatreInfo (INITIAL_THEATRE_INFO)
|
||||
setVideoLength (0)
|
||||
lastCommentNoRef.current = 0
|
||||
|
||||
void (async () => {
|
||||
setTheatre (await apiGet<Theatre> (`/theatres/${ id }`))
|
||||
}) ()
|
||||
|
||||
const interval = setInterval (async () => {
|
||||
if (theatreInfo.hostFlg
|
||||
&& theatreInfo.postStartedAt
|
||||
&& ((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 (() => {
|
||||
if (!(theatreInfo.hostFlg) || loading)
|
||||
return
|
||||
|
||||
if (theatreInfo.postId == null)
|
||||
try
|
||||
{
|
||||
void (async () => {
|
||||
setLoading (true)
|
||||
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
||||
setLoading (false)
|
||||
}) ()
|
||||
return
|
||||
const data = await apiGet<Theatre> (`/theatres/${ id }`)
|
||||
if (!(cancelled))
|
||||
setTheatre (data)
|
||||
}
|
||||
}, [id, loading, theatreInfo.hostFlg, theatreInfo.postId])
|
||||
catch (error)
|
||||
{
|
||||
setStatus ((error as any)?.response.status ?? 200)
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect (() => {
|
||||
if (!(id))
|
||||
return
|
||||
|
||||
let cancelled = false
|
||||
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 () => {
|
||||
setLoading (true)
|
||||
|
||||
try
|
||||
{
|
||||
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error (error)
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!(cancelled))
|
||||
setLoading (false)
|
||||
}
|
||||
}) ()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [id, theatreInfo.hostFlg, theatreInfo.postId])
|
||||
|
||||
useEffect (() => {
|
||||
setVideoLength (0)
|
||||
|
||||
if (theatreInfo.postId == null)
|
||||
return
|
||||
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
setPost (await fetchPost (String (theatreInfo.postId)))
|
||||
try
|
||||
{
|
||||
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) => {
|
||||
if (!(theatreInfo.postStartedAt))
|
||||
return
|
||||
|
||||
const targetTime =
|
||||
((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ())
|
||||
const targetTime = Math.min (
|
||||
Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()),
|
||||
videoLength)
|
||||
|
||||
const drift = Math.abs (meta.currentTime - targetTime)
|
||||
|
||||
@@ -88,8 +228,11 @@ export default (() => {
|
||||
embedRef.current?.seek (targetTime)
|
||||
}
|
||||
|
||||
if (status >= 400)
|
||||
return <ErrorScreen status={status}/>
|
||||
|
||||
return (
|
||||
<MainArea>
|
||||
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
|
||||
<Helmet>
|
||||
{theatre && (
|
||||
<title>
|
||||
@@ -99,16 +242,100 @@ export default (() => {
|
||||
</title>)}
|
||||
</Helmet>
|
||||
|
||||
{post && (
|
||||
<PostEmbed
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={meta => {
|
||||
syncPlayback (meta)
|
||||
}}/>)}
|
||||
</MainArea>)
|
||||
<div className="hidden md:block">
|
||||
{post && <TagDetailSidebar post={post}/>}
|
||||
</div>
|
||||
|
||||
<MainArea>
|
||||
{post ? (
|
||||
<>
|
||||
<PostEmbed
|
||||
key={post.id}
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={syncPlayback}/>
|
||||
<div className="m-2">
|
||||
<>再生中:</>
|
||||
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold">
|
||||
{post.title || post.url}
|
||||
</PrefetchLink>
|
||||
</div>
|
||||
</>) : 'Loading...'}
|
||||
</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
|
||||
|
||||
@@ -7,7 +7,6 @@ import PostList from '@/components/PostList'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TagLink from '@/components/TagLink'
|
||||
import WikiBody from '@/components/WikiBody'
|
||||
import PageTitle from '@/components/common/PageTitle'
|
||||
import TabGroup, { Tab } from '@/components/common/TabGroup'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
@@ -107,15 +106,15 @@ export default () => {
|
||||
</PrefetchLink>) : '(最新)'}
|
||||
</div>)}
|
||||
|
||||
<PageTitle>
|
||||
<TagLink tag={tag ?? defaultTag}
|
||||
withWiki={false}
|
||||
withCount={false}
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||
</PageTitle>
|
||||
<div className="prose mx-auto p-4">
|
||||
{loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
</div>
|
||||
<article className="prose dark:prose-invert mx-auto p-4">
|
||||
<h1 className="prose-a:no-underline">
|
||||
<TagLink tag={tag ?? defaultTag}
|
||||
withWiki={false}
|
||||
withCount={false}
|
||||
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
|
||||
</h1>
|
||||
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>}
|
||||
</article>
|
||||
|
||||
{(!(version) && posts.length > 0) && (
|
||||
<TabGroup>
|
||||
|
||||
+60
-12
@@ -49,12 +49,34 @@ export type FetchTagsParams = {
|
||||
limit: number
|
||||
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 MenuItem = {
|
||||
name: string
|
||||
export type MenuInvisibleItem = {
|
||||
name: ReactNode
|
||||
to?: string
|
||||
base?: string
|
||||
visible: false
|
||||
subMenu: SubMenuItem[] }
|
||||
|
||||
export type MenuItem = MenuVisibleItem | MenuInvisibleItem
|
||||
|
||||
export type MenuVisibleItem = {
|
||||
name: ReactNode
|
||||
to: string
|
||||
base?: string
|
||||
visible?: true
|
||||
subMenu: SubMenuItem[] }
|
||||
|
||||
export type NicoTag = Tag & {
|
||||
@@ -95,9 +117,9 @@ export type NiconicoViewerHandle = {
|
||||
export type Post = {
|
||||
id: number
|
||||
url: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
thumbnailBase: string
|
||||
title: string | null
|
||||
thumbnail: string | null
|
||||
thumbnailBase: string | null
|
||||
tags: Tag[]
|
||||
viewed: boolean
|
||||
related: Post[]
|
||||
@@ -105,7 +127,7 @@ export type Post = {
|
||||
originalCreatedBefore: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
uploadedUser: { id: number; name: string } | null }
|
||||
uploadedUser: { id: number; name: string | null } | null }
|
||||
|
||||
export type PostTagChange = {
|
||||
post: Post
|
||||
@@ -114,12 +136,30 @@ export type PostTagChange = {
|
||||
changeType: 'add' | 'remove'
|
||||
timestamp: string }
|
||||
|
||||
export type SubMenuItem =
|
||||
| { component: ReactNode
|
||||
visible: boolean }
|
||||
| { name: string
|
||||
to: string
|
||||
visible?: boolean }
|
||||
export type PostVersion = {
|
||||
postId: number
|
||||
versionNo: number
|
||||
eventType: 'create' | 'update' | 'discard' | 'restore'
|
||||
title: { current: string | null; prev: string | null }
|
||||
url: { current: string; prev: string | null }
|
||||
thumbnail: { current: string | null; prev: string | null }
|
||||
thumbnailBase: { current: string | null; prev: string | null }
|
||||
tags: { name: string; type: 'context' | 'added' | 'removed' }[]
|
||||
originalCreatedFrom: { current: string | null; prev: string | null }
|
||||
originalCreatedBefore: { current: string | null; prev: string | null }
|
||||
createdAt: string
|
||||
createdByUser: { id: number; name: string | null } | null }
|
||||
|
||||
export type SubMenuComponentItem = {
|
||||
component: ReactNode
|
||||
visible: boolean }
|
||||
|
||||
export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem
|
||||
|
||||
export type SubMenuStringItem = {
|
||||
name: ReactNode
|
||||
to: string
|
||||
visible?: boolean }
|
||||
|
||||
export type Tag = {
|
||||
id: number
|
||||
@@ -129,6 +169,7 @@ export type Tag = {
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
hasWiki: boolean
|
||||
materialId: number
|
||||
children?: Tag[]
|
||||
matchedAlias?: string | null }
|
||||
|
||||
@@ -141,6 +182,13 @@ export type Theatre = {
|
||||
createdAt: string
|
||||
updatedAt: string }
|
||||
|
||||
export type TheatreComment = {
|
||||
theatreId: number,
|
||||
no: number,
|
||||
user: { id: number, name: string } | null
|
||||
content: string
|
||||
createdAt: string }
|
||||
|
||||
export type User = {
|
||||
id: number
|
||||
name: string | null
|
||||
|
||||
@@ -8,7 +8,7 @@ import { DARK_COLOUR_SHADE,
|
||||
const colours = Object.values (TAG_COLOUR)
|
||||
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
|
||||
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'],
|
||||
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
|
||||
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
|
||||
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
|
||||
@@ -24,4 +24,4 @@ export default {
|
||||
'rainbow-scroll': {
|
||||
'0%': { backgroundPosition: '0% 50%' },
|
||||
'100%': { backgroundPosition: '200% 50%' } } } } },
|
||||
plugins: [] } satisfies Config
|
||||
plugins: [require ('@tailwindcss/typography')] } satisfies Config
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import mdx from '@mdx-js/rollup'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig ({
|
||||
plugins: [react()],
|
||||
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()],
|
||||
resolve: { alias: { '@': path.resolve (__dirname, './src') } },
|
||||
server: { host: true,
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user