Compare commits

..

14 Commits

Author SHA1 Message Date
みてるぞ 6dedf4148d #61 2026-03-21 19:53:54 +09:00
みてるぞ 930d020f2a #61 2026-03-21 15:41:05 +09:00
みてるぞ 8ccefd9782 Merge remote-tracking branch 'origin/main' into feature/061 2026-03-18 23:02:40 +09:00
みてるぞ 0eab847ebb #61 2026-03-15 19:54:37 +09:00
みてるぞ e399707fbf #61 2026-03-15 19:53:11 +09:00
みてるぞ a7afe5f4d5 #61 2026-03-15 16:07:32 +09:00
みてるぞ cac4ad7f51 #61 2026-03-15 15:25:32 +09:00
みてるぞ be14ae3ee4 #61 2026-03-15 15:23:07 +09:00
みてるぞ 5581d6e1cc #61 2026-03-15 02:48:13 +09:00
みてるぞ 1b56176cac #61 2026-03-15 02:33:28 +09:00
みてるぞ ea61f4a047 #61 2026-03-15 02:26:24 +09:00
みてるぞ c24ffad7dd #61 2026-03-15 02:13:45 +09:00
みてるぞ f8e4da6fcb #61 2026-03-14 20:59:24 +09:00
みてるぞ 790f39e95b 日づけ不詳の表示修正 2026-03-14 18:32:19 +09:00
64 changed files with 609 additions and 4413 deletions
+2 -2
View File
@@ -50,6 +50,8 @@ group :development, :test do
gem 'factory_bot_rails' gem 'factory_bot_rails'
end end
gem "mysql2", "~> 0.5.6" gem "mysql2", "~> 0.5.6"
gem "image_processing", "~> 1.14" gem "image_processing", "~> 1.14"
@@ -67,5 +69,3 @@ gem 'whenever', require: false
gem 'discard' gem 'discard'
gem "rspec-rails", "~> 8.0", :groups => [:development, :test] gem "rspec-rails", "~> 8.0", :groups => [:development, :test]
gem 'aws-sdk-s3', require: false
-21
View File
@@ -73,25 +73,6 @@ GEM
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
ast (2.4.3) ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1231.0)
aws-sdk-core (3.244.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.123.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.217.0)
aws-sdk-core (~> 3, >= 3.244.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.2.0) base64 (0.2.0)
bcrypt_pbkdf (1.1.1) bcrypt_pbkdf (1.1.1)
bcrypt_pbkdf (1.1.1-arm64-darwin) bcrypt_pbkdf (1.1.1-arm64-darwin)
@@ -176,7 +157,6 @@ GEM
pp (>= 0.6.0) pp (>= 0.6.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2)
json (2.12.0) json (2.12.0)
jwt (2.10.1) jwt (2.10.1)
base64 base64
@@ -461,7 +441,6 @@ PLATFORMS
x86_64-linux-musl x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
aws-sdk-s3
bootsnap bootsnap
brakeman brakeman
diff-lcs diff-lcs
@@ -1,101 +0,0 @@
class MaterialsController < ApplicationController
def index
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
tag_id = params[:tag_id].presence
parent_id = params[:parent_id].presence
q = Material.includes(:tag, :created_by_user).with_attached_file
q = q.where(tag_id:) if tag_id
q = q.where(parent_id:) if parent_id
count = q.count
materials = q.order(created_at: :desc, id: :desc).limit(limit).offset(offset)
render json: { materials: MaterialRepr.many(materials, host: request.base_url), count: count }
end
def show
material =
Material
.includes(:tag)
.with_attached_file
.find_by(id: params[:id])
return head :not_found unless material
wiki_page_body = material.tag.tag_name.wiki_page&.current_revision&.body
render json: MaterialRepr.base(material, host: request.base_url).merge(wiki_page_body:)
end
def create
return head :unauthorized unless current_user
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material = Material.new(tag:, url:,
created_by_user: current_user,
updated_by_user: current_user)
material.file.attach(file)
if material.save
render json: MaterialRepr.base(material, host: request.base_url), status: :created
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end
def update
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
material = Material.with_attached_file.find_by(id: params[:id])
return head :not_found unless material
tag_name_raw = params[:tag].to_s.strip
file = params[:file]
url = params[:url].to_s.strip.presence
return head :bad_request if tag_name_raw.blank? || (file.blank? && url.blank?)
tag_name = TagName.find_undiscard_or_create_by!(name: tag_name_raw)
tag = tag_name.tag
tag = Tag.create!(tag_name:, category: :material) unless tag
material.update!(tag:, url:, updated_by_user: current_user)
if file
material.file.attach(file)
else
material.file.purge
end
if material.save
render json: MaterialRepr.base(material, host: request.base_url)
else
render json: { errors: material.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member?
material = Material.find_by(id: params[:id])
return head :not_found unless material
material.discard
head :no_content
end
end
+15 -21
View File
@@ -127,20 +127,17 @@ class PostsController < ApplicationController
post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user,
original_created_from:, original_created_before:) original_created_from:, original_created_before:)
post.thumbnail.attach(thumbnail) post.thumbnail.attach(thumbnail)
if post.save
ActiveRecord::Base.transaction do post.resized_thumbnail!
post.save!
tags = Tag.normalise_tags(tag_names) tags = Tag.normalise_tags(tag_names)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)
post.resized_thumbnail!
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
end
post.reload post.reload
render json: PostRepr.base(post), status: :created render json: PostRepr.base(post), status: :created
rescue ActiveRecord::RecordInvalid else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
end end
@@ -169,22 +166,19 @@ class PostsController < ApplicationController
original_created_before = params[:original_created_before] original_created_before = params[:original_created_before]
post = Post.find(params[:id].to_i) post = Post.find(params[:id].to_i)
if post.update(title:, original_created_from:, original_created_before:)
ActiveRecord::Base.transaction do
post.update!(title:, original_created_from:, original_created_before:)
tags = post.tags.where(category: 'nico').to_a + tags = post.tags.where(category: 'nico').to_a +
Tag.normalise_tags(tag_names, with_tagme: false) Tag.normalise_tags(tag_names, with_tagme: false)
tags = Tag.expand_parent_tags(tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags) sync_post_tags!(post, tags)
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end
post.reload post.reload
json = post.as_json json = post.as_json
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
rescue ActiveRecord::RecordInvalid else
render json: post.errors, status: :unprocessable_entity render json: post.errors, status: :unprocessable_entity
end
rescue Tag::NicoTagNormalisationError rescue Tag::NicoTagNormalisationError
head :bad_request head :bad_request
end end
+5 -71
View File
@@ -33,11 +33,11 @@ class TagsController < ApplicationController
else else
Tag.joins(:tag_name) Tag.joins(:tag_name)
end end
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
q = q.where(posts: { id: post_id }) if post_id.present? q = q.where(posts: { id: post_id }) if post_id.present?
q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name q = q.where('tag_names.name LIKE ?', "%#{ name }%") if name
q = q.where(category:) if category q = q.where(category: category) if category
q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0] q = q.where('tags.post_count >= ?', post_count_between[0]) if post_count_between[0]
q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1] q = q.where('tags.post_count <= ?', post_count_between[1]) if post_count_between[1]
q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0] q = q.where('tags.created_at >= ?', created_between[0]) if created_between[0]
@@ -69,44 +69,6 @@ class TagsController < ApplicationController
render json: { tags: TagRepr.base(tags), count: q.size } render json: { tags: TagRepr.base(tags), count: q.size }
end end
def with_depth
parent_tag_id = params[:parent].to_i
parent_tag_id = nil if parent_tag_id <= 0
tag_ids =
if parent_tag_id
TagImplication.where(parent_tag_id:).select(:tag_id)
else
Tag.where.not(id: TagImplication.select(:tag_id)).select(:id)
end
tags =
Tag
.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.where(category: [:meme, :character, :material])
.where(id: tag_ids)
.order('tag_names.name')
.distinct
.to_a
has_children_tag_ids =
if tags.empty?
[]
else
TagImplication
.joins(:tag)
.where(parent_tag_id: tags.map(&:id),
tags: { category: [:meme, :character, :material] })
.distinct
.pluck(:parent_tag_id)
end
render json: tags.map { |tag|
TagRepr.base(tag).merge(has_children: has_children_tag_ids.include?(tag.id), children: [])
}
end
def autocomplete def autocomplete
q = params[:q].to_s.strip.sub(/\Anot:/i, '') q = params[:q].to_s.strip.sub(/\Anot:/i, '')
@@ -128,7 +90,7 @@ class TagsController < ApplicationController
end end
base = Tag.joins(:tag_name) base = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
base = base.where('tags.post_count > 0') if present_only base = base.where('tags.post_count > 0') if present_only
canonical_hit = canonical_hit =
@@ -153,7 +115,7 @@ class TagsController < ApplicationController
def show def show
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
.find_by(id: params[:id]) .find_by(id: params[:id])
if tag if tag
render json: TagRepr.base(tag) render json: TagRepr.base(tag)
@@ -167,7 +129,7 @@ class TagsController < ApplicationController
return head :bad_request if name.blank? return head :bad_request if name.blank?
tag = Tag.joins(:tag_name) tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page) .includes(:tag_name, tag_name: :wiki_page)
.find_by(tag_names: { name: }) .find_by(tag_names: { name: })
if tag if tag
render json: TagRepr.base(tag) render json: TagRepr.base(tag)
@@ -197,18 +159,6 @@ class TagsController < ApplicationController
render json: DeerjikistRepr.many(tag.deerjikists) render json: DeerjikistRepr.many(tag.deerjikists)
end end
def materials_by_name
name = params[:name].to_s.strip
return head :bad_request if name.blank?
tag = Tag.joins(:tag_name)
.includes(:tag_name, :materials, tag_name: :wiki_page)
.find_by(tag_names: { name: })
return head :not_found unless tag
render json: build_tag_children(tag)
end
def update def update
return head :unauthorized unless current_user return head :unauthorized unless current_user
return head :forbidden unless current_user.gte_member? return head :forbidden unless current_user.gte_member?
@@ -228,20 +178,4 @@ class TagsController < ApplicationController
render json: TagRepr.base(tag) render json: TagRepr.base(tag)
end end
private
def build_tag_children(tag)
material = tag.materials.first
file = nil
content_type = nil
if material&.file&.attached?
file = rails_storage_proxy_url(material.file, only_path: false)
content_type = material.file.blob.content_type
end
TagRepr.base(tag).merge(
children: tag.children.sort_by { _1.name }.map { build_tag_children(_1) },
material: material.as_json&.merge(file:, content_type:))
end
end end
@@ -1,32 +0,0 @@
class TheatreCommentsController < ApplicationController
def index
no_gt = params[:no_gt].to_i
no_gt = 0 if no_gt.negative?
comments = TheatreComment
.where(theatre_id: params[:theatre_id])
.where('no > ?', no_gt)
.order(no: :desc)
render json: comments.as_json(include: { user: { only: [:id, :name] } })
end
def create
return head :unauthorized unless current_user
content = params[:content]
return head :unprocessable_entity if content.blank?
theatre = Theatre.find_by(id: params[:theatre_id])
return head :not_found unless theatre
comment = nil
theatre.with_lock do
no = theatre.next_comment_no
comment = TheatreComment.create!(theatre:, no:, user: current_user, content:)
theatre.update!(next_comment_no: no + 1)
end
render json: comment, status: :created
end
end
@@ -31,9 +31,7 @@ class TheatresController < ApplicationController
post_started_at = theatre.current_post_started_at post_started_at = theatre.current_post_started_at
end end
render json: { render json: { host_flg:, post_id:, post_started_at: }
host_flg:, post_id:, post_started_at:,
watching_users: theatre.watching_users.as_json(only: [:id, :name]) }
end end
def next_post def next_post
-39
View File
@@ -1,39 +0,0 @@
class Material < ApplicationRecord
include MyDiscard
default_scope -> { kept }
belongs_to :parent, class_name: 'Material', optional: true
has_many :children, class_name: 'Material', foreign_key: :parent_id, dependent: :nullify
belongs_to :tag, optional: true
belongs_to :created_by_user, class_name: 'User', optional: true
belongs_to :updated_by_user, class_name: 'User', optional: true
has_one_attached :file, dependent: :purge
validates :tag_id, presence: true, uniqueness: true
validate :file_must_be_attached
validate :tag_must_be_material_category
def content_type
return nil unless file&.attached?
file.blob.content_type
end
private
def file_must_be_attached
return if url.present? || file.attached?
errors.add(:url, 'URL かファイルのどちらかは必須です.')
end
def tag_must_be_material_category
return if tag.blank? || tag.character? || tag.material?
errors.add(:tag, '素材カテゴリのタグを指定してください.')
end
end
-3
View File
@@ -11,7 +11,6 @@ class Post < ApplicationRecord
has_many :user_post_views, dependent: :delete_all has_many :user_post_views, dependent: :delete_all
has_many :post_similarities, dependent: :delete_all has_many :post_similarities, dependent: :delete_all
has_many :post_versions
has_one_attached :thumbnail has_one_attached :thumbnail
@@ -31,8 +30,6 @@ class Post < ApplicationRecord
super(options).merge(thumbnail: nil) super(options).merge(thumbnail: nil)
end end
def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name')
def related limit: nil def related limit: nil
ids = post_similarities.order(cos: :desc) ids = post_similarities.order(cos: :desc)
ids = ids.limit(limit) if limit ids = ids.limit(limit) if limit
-38
View File
@@ -1,38 +0,0 @@
class PostVersion < ApplicationRecord
before_update do
raise ActiveRecord::ReadOnlyRecord, '版は更新できません.'
end
before_destroy do
raise ActiveRecord::ReadOnlyRecord, '版は削除できません.'
end
belongs_to :post
belongs_to :parent, class_name: 'Post', optional: true
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, inclusion: { in: event_types.keys }
validates :url, presence: true
validate :validate_original_created_range
scope :chronological, -> { order(:version_no, :id) }
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
-13
View File
@@ -1,6 +1,3 @@
require 'set'
class Tag < ApplicationRecord class Tag < ApplicationRecord
include MyDiscard include MyDiscard
@@ -34,7 +31,6 @@ class Tag < ApplicationRecord
class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all class_name: 'TagSimilarity', foreign_key: :target_tag_id, dependent: :delete_all
has_many :deerjikists, dependent: :delete_all has_many :deerjikists, dependent: :delete_all
has_many :materials
belongs_to :tag_name belongs_to :tag_name
delegate :wiki_page, to: :tag_name delegate :wiki_page, to: :tag_name
@@ -76,8 +72,6 @@ class Tag < ApplicationRecord
def has_wiki = wiki_page.present? def has_wiki = wiki_page.present?
def material_id = materials.first&.id
def self.tagme def self.tagme
@tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta) @tagme ||= find_or_create_by_tag_name!('タグ希望', category: :meta)
end end
@@ -153,8 +147,6 @@ class Tag < ApplicationRecord
def self.merge_tags! target_tag, source_tags def self.merge_tags! target_tag, source_tags
target_tag => Tag target_tag => Tag
affected_post_ids = Set.new
Tag.transaction do Tag.transaction do
Array(source_tags).compact.uniq.each do |source_tag| Array(source_tags).compact.uniq.each do |source_tag|
source_tag => Tag source_tag => Tag
@@ -163,7 +155,6 @@ class Tag < ApplicationRecord
source_tag.post_tags.kept.find_each do |source_pt| source_tag.post_tags.kept.find_each do |source_pt|
post_id = source_pt.post_id post_id = source_pt.post_id
affected_post_ids << post_id
source_pt.discard_by!(nil) source_pt.discard_by!(nil)
unless PostTag.kept.exists?(post_id:, tag: target_tag) unless PostTag.kept.exists?(post_id:, tag: target_tag)
PostTag.create!(post_id:, tag: target_tag) PostTag.create!(post_id:, tag: target_tag)
@@ -186,10 +177,6 @@ class Tag < ApplicationRecord
end end
end end
Post.where(id: affected_post_ids.to_a).find_each do |post|
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: nil)
end
# 投稿件数を再集計 # 投稿件数を再集計
target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count) target_tag.update_columns(post_count: PostTag.kept.where(tag: target_tag).count)
end end
+2 -2
View File
@@ -1,8 +1,8 @@
class TheatreComment < ApplicationRecord class TheatreComment < ApplicationRecord
include Discard::Model include MyDiscard
self.primary_key = :theatre_id, :no self.primary_key = :theatre_id, :no
belongs_to :theatre belongs_to :theatre
belongs_to :user
end end
@@ -1,24 +0,0 @@
# frozen_string_literal: true
module MaterialRepr
BASE = { only: [:id, :url, :created_at, :updated_at],
methods: [:content_type],
include: { tag: TagRepr::BASE,
created_by_user: UserRepr::BASE,
updated_by_user: UserRepr::BASE } }.freeze
module_function
def base material, host:
material.as_json(BASE).merge(
file: if material.file.attached?
Rails.application.routes.url_helpers.rails_storage_proxy_url(
material.file, host:)
end)
end
def many materials, host:
materials.map { |m| base(m, host:) }
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
module TagRepr module TagRepr
BASE = { only: [:id, :category, :post_count, :created_at, :updated_at], BASE = { only: [:id, :category, :post_count, :created_at, :updated_at],
methods: [:name, :has_wiki, :material_id] }.freeze methods: [:name, :has_wiki] }.freeze
module_function module_function
@@ -1,57 +0,0 @@
class PostVersionRecorder
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:
@post = post
@event_type = event_type
@created_by_user = created_by_user
end
def record!
@post.with_lock do
latest = @post.post_versions.order(version_no: :desc).first
attrs = snapshot_attributes
return latest if @event_type == :update && latest && same_snapshot?(latest, attrs)
PostVersion.create!(
post: @post,
version_no: (latest&.version_no || 0) + 1,
event_type: @event_type,
title: attrs[:title],
url: attrs[:url],
thumbnail_base: attrs[:thumbnail_base],
tags: attrs[:tags],
parent: attrs[:parent],
original_created_from: attrs[:original_created_from],
original_created_before: attrs[:original_created_before],
created_at: Time.current,
created_by_user: @created_by_user)
end
end
private
def snapshot_attributes
{ title: @post.title,
url: @post.url,
thumbnail_base: @post.thumbnail_base,
tags: @post.snapshot_tag_names.join(' '),
parent: @post.parent,
original_created_from: @post.original_created_from,
original_created_before: @post.original_created_before }
end
def same_snapshot? version, attrs
true &&
version.title == attrs[:title] &&
version.url == attrs[:url] &&
version.thumbnail_base == attrs[:thumbnail_base] &&
version.tags == attrs[:tags] &&
version.parent_id == attrs[:parent]&.id &&
version.original_created_from == attrs[:original_created_from] &&
version.original_created_before == attrs[:original_created_before]
end
end
+2 -1
View File
@@ -18,7 +18,8 @@ Rails.application.configure do
# Enable serving of images, stylesheets, and JavaScripts from an asset server. # Enable serving of images, stylesheets, and JavaScripts from an asset server.
# config.asset_host = "http://assets.example.com" # config.asset_host = "http://assets.example.com"
config.active_storage.service = :r2 # Store uploaded files on the local file system (see config/storage.yml for options).
config.active_storage.service = :local
# Assume all access to the app is happening through a SSL-terminating reverse proxy. # Assume all access to the app is happening through a SSL-terminating reverse proxy.
config.assume_ssl = true config.assume_ssl = true
-2
View File
@@ -50,6 +50,4 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
Rails.application.routes.default_url_options[:host] = 'www.example.com'
end end
-6
View File
@@ -9,11 +9,9 @@ Rails.application.routes.draw do
resources :tags, only: [:index, :show, :update] do resources :tags, only: [:index, :show, :update] do
collection do collection do
get :autocomplete get :autocomplete
get :'with-depth', action: :with_depth
scope :name do scope :name do
get ':name/deerjikists', action: :deerjikists_by_name get ':name/deerjikists', action: :deerjikists_by_name
get ':name/materials', action: :materials_by_name
get ':name', action: :show_by_name get ':name', action: :show_by_name
end end
end end
@@ -80,9 +78,5 @@ Rails.application.routes.draw do
put :watching put :watching
patch :next_post patch :next_post
end end
resources :comments, controller: :theatre_comments, only: [:index, :create]
end end
resources :materials, only: [:index, :show, :create, :update, :destroy]
end end
+26 -8
View File
@@ -6,11 +6,29 @@ local:
service: Disk service: Disk
root: <%= Rails.root.join("storage") %> root: <%= Rails.root.join("storage") %>
r2: # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key)
service: S3 # amazon:
endpoint: <%= ENV['R2_ENDPOINT'] %> # service: S3
access_key_id: <%= ENV['R2_ACCESS_KEY_ID'] %> # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %>
secret_access_key: <%= ENV['R2_SECRET_ACCESS_KEY'] %> # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %>
bucket: <%= ENV['R2_BUCKET'] %> # region: us-east-1
region: auto # bucket: your_own_bucket-<%= Rails.env %>
request_checksum_calculation: when_required
# Remember not to checkin your GCS keyfile to a repository
# google:
# service: GCS
# project: your_project
# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %>
# bucket: your_own_bucket-<%= Rails.env %>
# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key)
# microsoft:
# service: AzureStorage
# storage_account_name: your_account_name
# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %>
# container: your_container_name-<%= Rails.env %>
# mirror:
# service: Mirror
# primary: local
# mirrors: [ amazon, google, microsoft ]
@@ -1,34 +0,0 @@
class CreateMaterials < ActiveRecord::Migration[8.0]
def change
create_table :materials do |t|
t.string :url
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true
t.virtual :active_url, type: :string,
as: 'IF(discarded_at IS NULL, url, NULL)',
stored: false
t.index :active_url, unique: true
end
create_table :material_versions do |t|
t.references :material, null: false, foreign_key: true
t.integer :version_no, null: false
t.string :url, index: true
t.references :parent, index: true, foreign_key: { to_table: :materials }
t.references :tag, index: true, foreign_key: true
t.references :created_by_user, foreign_key: { to_table: :users }
t.references :updated_by_user, foreign_key: { to_table: :users }
t.timestamps
t.datetime :discarded_at, index: true
t.index [:material_id, :version_no],
unique: true,
name: 'index_material_versions_on_material_id_and_version_no'
end
end
end
@@ -1,203 +0,0 @@
require 'set'
class CreatePostVersions < ActiveRecord::Migration[8.0]
class Post < ApplicationRecord
self.table_name = 'posts'
end
class PostTag < ApplicationRecord
self.table_name = 'post_tags'
end
class PostVersion < ApplicationRecord
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
+1 -89
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -56,45 +56,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true t.index ["ip_address"], name: "index_ip_addresses_on_ip_address", unique: true
end end
create_table "material_versions", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "material_id", null: false
t.integer "version_no", null: false
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.index ["created_by_user_id"], name: "index_material_versions_on_created_by_user_id"
t.index ["discarded_at"], name: "index_material_versions_on_discarded_at"
t.index ["material_id", "version_no"], name: "index_material_versions_on_material_id_and_version_no", unique: true
t.index ["material_id"], name: "index_material_versions_on_material_id"
t.index ["parent_id"], name: "index_material_versions_on_parent_id"
t.index ["tag_id"], name: "index_material_versions_on_tag_id"
t.index ["updated_by_user_id"], name: "index_material_versions_on_updated_by_user_id"
t.index ["url"], name: "index_material_versions_on_url"
end
create_table "materials", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "url"
t.bigint "parent_id"
t.bigint "tag_id"
t.bigint "created_by_user_id"
t.bigint "updated_by_user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.datetime "discarded_at"
t.virtual "active_url", type: :string, as: "if((`discarded_at` is null),`url`,NULL)"
t.index ["active_url"], name: "index_materials_on_active_url", unique: true
t.index ["created_by_user_id"], name: "index_materials_on_created_by_user_id"
t.index ["discarded_at"], name: "index_materials_on_discarded_at"
t.index ["parent_id"], name: "index_materials_on_parent_id"
t.index ["tag_id"], name: "index_materials_on_tag_id"
t.index ["updated_by_user_id"], name: "index_materials_on_updated_by_user_id"
end
create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "nico_tag_relations", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "nico_tag_id", null: false t.bigint "nico_tag_id", null: false
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
@@ -132,27 +93,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.index ["tag_id"], name: "index_post_tags_on_tag_id" t.index ["tag_id"], name: "index_post_tags_on_tag_id"
end 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| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "title" t.string "title"
t.string "url", limit: 768, null: false t.string "url", limit: 768, null: false
@@ -299,19 +239,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -327,7 +254,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
@@ -366,15 +292,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "material_versions", "materials"
add_foreign_key "material_versions", "materials", column: "parent_id"
add_foreign_key "material_versions", "tags"
add_foreign_key "material_versions", "users", column: "created_by_user_id"
add_foreign_key "material_versions", "users", column: "updated_by_user_id"
add_foreign_key "materials", "materials", column: "parent_id"
add_foreign_key "materials", "tags"
add_foreign_key "materials", "users", column: "created_by_user_id"
add_foreign_key "materials", "users", column: "updated_by_user_id"
add_foreign_key "nico_tag_relations", "tags" add_foreign_key "nico_tag_relations", "tags"
add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id"
add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts"
@@ -383,9 +300,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "tags"
add_foreign_key "post_tags", "users", column: "created_user_id" add_foreign_key "post_tags", "users", column: "created_user_id"
add_foreign_key "post_tags", "users", column: "deleted_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", "posts", column: "parent_id"
add_foreign_key "posts", "users", column: "uploaded_user_id" add_foreign_key "posts", "users", column: "uploaded_user_id"
add_foreign_key "settings", "users" add_foreign_key "settings", "users"
@@ -406,8 +320,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_09_123700) do
add_foreign_key "user_ips", "users" add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+1 -12
View File
@@ -61,9 +61,6 @@ namespace :nico do
original_created_from = original_created_at&.change(sec: 0) original_created_from = original_created_at&.change(sec: 0)
original_created_before = original_created_from&.+(1.minute) original_created_before = original_created_from&.+(1.minute)
post_created = false
post_changed = false
if post if post
attrs = { title:, original_created_from:, original_created_before: } attrs = { title:, original_created_from:, original_created_before: }
@@ -79,13 +76,11 @@ namespace :nico do
end end
post.assign_attributes(attrs) post.assign_attributes(attrs)
post_changed = post.changed? if post.changed?
if post_changed
post.save! post.save!
post.resized_thumbnail! if post.thumbnail.attached? post.resized_thumbnail! if post.thumbnail.attached?
end end
else else
post_created = true
url = "https://www.nicovideo.jp/watch/#{ code }" url = "https://www.nicovideo.jp/watch/#{ code }"
thumbnail_base = fetch_thumbnail.(url) rescue nil thumbnail_base = fetch_thumbnail.(url) rescue nil
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil, post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil,
@@ -145,12 +140,6 @@ namespace :nico do
desired_all_tag_ids.uniq! desired_all_tag_ids.uniq!
sync_post_tags!(post, desired_all_tag_ids, current_tag_ids: kept_tag_ids) 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.record!(post:, event_type: :update, created_by_user: nil)
end
end end
end end
end end
@@ -1,8 +0,0 @@
FactoryBot.define do
factory :theatre_comment do
association :theatre
association :user
sequence (:no) { |n| n }
content { 'test comment' }
end
end
-11
View File
@@ -1,11 +0,0 @@
FactoryBot.define do
factory :theatre do
name { 'Test Theatre' }
kind { 1 }
opens_at { Time.current }
closes_at { 1.day.from_now }
next_comment_no { 1 }
association :created_by_user, factory: :user
end
end
-41
View File
@@ -1,41 +0,0 @@
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
-65
View File
@@ -145,70 +145,5 @@ RSpec.describe Tag, type: :model do
expect(target_tag.reload.post_count).to eq(0) expect(target_tag.reload.post_count).to eq(0)
end end
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
end end
-378
View File
@@ -1,378 +0,0 @@
require 'rails_helper'
RSpec.describe 'Materials API', type: :request do
let!(:member_user) { create(:user, :member) }
let!(:guest_user) { create(:user) }
def dummy_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end
def response_materials
json.fetch('materials')
end
def build_material(tag:, user:, parent: nil, file: dummy_upload, url: nil)
Material.new(tag:, parent:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(file) if file
material.save!
end
end
describe 'GET /materials' do
let!(:tag_a) { Tag.create!(tag_name: TagName.create!(name: 'material_index_a'), category: :material) }
let!(:tag_b) { Tag.create!(tag_name: TagName.create!(name: 'material_index_b'), category: :material) }
let!(:material_a) do
build_material(tag: tag_a, user: member_user, file: dummy_upload(filename: 'a.png'))
end
let!(:material_b) do
build_material(tag: tag_b, user: member_user, parent: material_a, file: dummy_upload(filename: 'b.png'))
end
before do
old_time = Time.zone.local(2026, 3, 29, 1, 0, 0)
new_time = Time.zone.local(2026, 3, 29, 2, 0, 0)
material_a.update_columns(created_at: old_time, updated_at: old_time)
material_b.update_columns(created_at: new_time, updated_at: new_time)
end
it 'returns materials with count and metadata' do
get '/materials'
expect(response).to have_http_status(:ok)
expect(json).to include('materials', 'count')
expect(response_materials).to be_an(Array)
expect(json['count']).to eq(2)
row = response_materials.find { |m| m['id'] == material_b.id }
expect(row).to be_present
expect(row['tag']).to include(
'id' => tag_b.id,
'name' => 'material_index_b',
'category' => 'material'
)
expect(row['created_by_user']).to include(
'id' => member_user.id,
'name' => member_user.name
)
expect(row['content_type']).to eq('image/png')
end
it 'filters materials by tag_id' do
get '/materials', params: { tag_id: material_a.tag_id }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_a.id])
end
it 'filters materials by parent_id' do
get '/materials', params: { parent_id: material_a.id }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(1)
expect(response_materials.map { |m| m['id'] }).to eq([material_b.id])
end
it 'paginates and keeps total count' do
get '/materials', params: { page: 2, limit: 1 }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_a.id)
end
it 'normalises invalid page and limit' do
get '/materials', params: { page: 0, limit: 0 }
expect(response).to have_http_status(:ok)
expect(json['count']).to eq(2)
expect(response_materials.size).to eq(1)
expect(response_materials.first['id']).to eq(material_b.id)
end
end
describe 'GET /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_show'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'show.png'))
end
it 'returns a material with file, tag, and content_type' do
get "/materials/#{ material.id }"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => material.id,
'content_type' => 'image/png'
)
expect(json['file']).to be_present
expect(json['tag']).to include(
'id' => tag.id,
'name' => 'material_show',
'category' => 'material'
)
end
it 'returns 404 when material does not exist' do
get '/materials/999999999'
expect(response).to have_http_status(:not_found)
end
end
describe 'POST /materials' do
context 'when not logged in' do
before { sign_out }
it 'returns 401' do
post '/materials', params: {
tag: 'material_create_unauthorized',
file: dummy_upload
}
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in' do
before { sign_in_as(guest_user) }
it 'returns 400 when tag is blank' do
post '/materials', params: { tag: ' ', file: dummy_upload }
expect(response).to have_http_status(:bad_request)
end
it 'returns 400 when both file and url are blank' do
post '/materials', params: { tag: 'material_create_blank' }
expect(response).to have_http_status(:bad_request)
end
it 'creates a material with an attached file' do
expect do
post '/materials', params: {
tag: 'material_create_new',
file: dummy_upload(filename: 'created.png')
}
end.to change(Material, :count).by(1)
.and change(Tag, :count).by(1)
.and change(TagName, :count).by(1)
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_new')
expect(material.tag.category).to eq('material')
expect(material.created_by_user).to eq(guest_user)
expect(material.updated_by_user).to eq(guest_user)
expect(material.file.attached?).to be(true)
expect(json['id']).to eq(material.id)
expect(json.dig('tag', 'name')).to eq('material_create_new')
expect(json['content_type']).to eq('image/png')
end
it 'returns 422 when the existing tag is not material/character' do
general_tag_name = TagName.create!(name: 'material_create_general_tag')
Tag.create!(tag_name: general_tag_name, category: :general)
post '/materials', params: {
tag: 'material_create_general_tag',
file: dummy_upload
}
expect(response).to have_http_status(:unprocessable_entity)
end
it 'persists url-only material' do
expect do
post '/materials', params: {
tag: 'material_create_url_only',
url: 'https://example.com/material-source'
}
end.to change(Material, :count).by(1)
expect(response).to have_http_status(:created)
material = Material.order(:id).last
expect(material.tag.name).to eq('material_create_url_only')
expect(material.url).to eq('https://example.com/material-source')
expect(material.file.attached?).to be(false)
end
it 'returns the original url for url-only material' do
post '/materials', params: {
tag: 'material_create_url_only_response',
url: 'https://example.com/material-source'
}
expect(response).to have_http_status(:created)
expect(json['url']).to eq('https://example.com/material-source')
end
end
end
describe 'PUT /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_update_old'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'old.png'))
end
context 'when not logged in' do
before { sign_out }
it 'returns 401' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
before { sign_in_as(guest_user) }
it 'returns 403' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
file: dummy_upload(filename: 'new.png')
}
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before { sign_in_as(member_user) }
it 'returns 404 when material does not exist' do
put '/materials/999999999', params: {
tag: 'material_update_missing',
file: dummy_upload
}
expect(response).to have_http_status(:not_found)
end
it 'returns 400 when tag is blank' do
put "/materials/#{ material.id }", params: {
tag: ' ',
file: dummy_upload
}
expect(response).to have_http_status(:bad_request)
end
it 'returns 400 when both file and url are blank' do
put "/materials/#{ material.id }", params: {
tag: 'material_update_no_payload'
}
expect(response).to have_http_status(:bad_request)
end
it 'updates tag, url, file, and updated_by_user' do
old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: {
tag: 'material_update_new',
url: 'https://example.com/updated-source',
file: dummy_upload(filename: 'updated.jpg', type: 'image/jpeg')
}
expect(response).to have_http_status(:ok)
material.reload
expect(material.tag.name).to eq('material_update_new')
expect(material.tag.category).to eq('material')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(true)
expect(material.file.blob.id).not_to eq(old_blob_id)
expect(material.file.blob.filename.to_s).to eq('updated.jpg')
expect(material.file.blob.content_type).to eq('image/jpeg')
expect(json['id']).to eq(material.id)
expect(json['file']).to be_present
expect(json['content_type']).to eq('image/jpeg')
expect(json.dig('tag', 'name')).to eq('material_update_new')
end
it 'purges the existing file when file is omitted and url is provided' do
old_blob_id = material.file.blob.id
put "/materials/#{ material.id }", params: {
tag: 'material_update_remove_file',
url: 'https://example.com/updated-source'
}
expect(response).to have_http_status(:ok)
material.reload
expect(material.tag.name).to eq('material_update_remove_file')
expect(material.url).to eq('https://example.com/updated-source')
expect(material.updated_by_user).to eq(member_user)
expect(material.file.attached?).to be(false)
expect(
ActiveStorage::Blob.where(id: old_blob_id).exists?
).to be(false)
expect(json['id']).to eq(material.id)
expect(json['file']).to be_nil
expect(json['content_type']).to be_nil
expect(json.dig('tag', 'name')).to eq('material_update_remove_file')
expect(json['url']).to eq('https://example.com/updated-source')
end
end
end
describe 'DELETE /materials/:id' do
let!(:tag) { Tag.create!(tag_name: TagName.create!(name: 'material_destroy'), category: :material) }
let!(:material) do
build_material(tag:, user: member_user, file: dummy_upload(filename: 'destroy.png'))
end
context 'when not logged in' do
before { sign_out }
it 'returns 401' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when logged in but not member' do
before { sign_in_as(guest_user) }
it 'returns 403' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:forbidden)
end
end
context 'when member' do
before { sign_in_as(member_user) }
it 'returns 404 when material does not exist' do
delete '/materials/999999999'
expect(response).to have_http_status(:not_found)
end
it 'discards the material and returns 204' do
delete "/materials/#{ material.id }"
expect(response).to have_http_status(:no_content)
expect(Material.find_by(id: material.id)).to be_nil
expect(Material.with_discarded.find(material.id)).to be_discarded
end
end
end
end
-123
View File
@@ -795,127 +795,4 @@ RSpec.describe 'Posts API', type: :request do
expect(user.reload.viewed?(post_record)).to be(false) expect(user.reload.viewed?(post_record)).to be(false)
end end
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)
create_post_version_for!(post_record)
expect do
put "/posts/#{post_record.id}", params: {
title: post_record.title,
tags: 'spec_tag'
}
end.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
end end
-151
View File
@@ -19,17 +19,6 @@ RSpec.describe 'Tags API', type: :request do
response_tags.map { |t| t.fetch('name') } response_tags.map { |t| t.fetch('name') }
end end
def dummy_material_upload(filename: 'dummy.png', type: 'image/png', body: 'dummy')
Rack::Test::UploadedFile.new(StringIO.new(body), type, original_filename: filename)
end
def create_material(tag, user:, filename: 'dummy.png', type: 'image/png', url: nil)
Material.new(tag:, url:, created_by_user: user, updated_by_user: user).tap do |material|
material.file.attach(dummy_material_upload(filename:, type:)) if filename
material.save!
end
end
describe 'GET /tags' do describe 'GET /tags' do
it 'returns tags with count and metadata' do it 'returns tags with count and metadata' do
get '/tags' get '/tags'
@@ -370,144 +359,4 @@ RSpec.describe 'Tags API', type: :request do
end end
end end
end end
describe 'GET /tags/with-depth' do
let!(:root_meme) do
Tag.create!(tag_name: TagName.create!(name: 'depth_a_root_meme'), category: :meme)
end
let!(:root_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_b_root_material'), category: :material)
end
let!(:hidden_general_root) do
Tag.create!(tag_name: TagName.create!(name: 'depth_hidden_general_root'), category: :general)
end
let!(:child_character) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_character'), category: :character)
end
let!(:grandchild_material) do
Tag.create!(tag_name: TagName.create!(name: 'depth_grandchild_material'), category: :material)
end
let!(:child_general) do
Tag.create!(tag_name: TagName.create!(name: 'depth_child_general'), category: :general)
end
before do
TagImplication.create!(parent_tag: root_meme, tag: child_character)
TagImplication.create!(parent_tag: child_character, tag: grandchild_material)
TagImplication.create!(parent_tag: root_material, tag: child_general)
end
it 'returns only visible root tags and visible has_children flags' do
get '/tags/with-depth'
expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq([
'depth_a_root_meme',
'depth_b_root_material'
])
meme_row = json.find { |t| t['name'] == 'depth_a_root_meme' }
material_row = json.find { |t| t['name'] == 'depth_b_root_material' }
expect(meme_row['has_children']).to eq(true)
expect(meme_row['children']).to eq([])
expect(material_row['has_children']).to eq(false)
expect(material_row['children']).to eq([])
expect(json.map { |t| t['name'] }).not_to include('depth_hidden_general_root')
end
it 'returns children of the specified parent' do
get '/tags/with-depth', params: { parent: root_meme.id }
expect(response).to have_http_status(:ok)
expect(json.map { |t| t['name'] }).to eq(['depth_child_character'])
row = json.first
expect(row['category']).to eq('character')
expect(row['has_children']).to eq(true)
expect(row['children']).to eq([])
end
end
describe 'GET /tags/name/:name/materials' do
let!(:material_user) { create_member_user! }
let!(:root_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_root'), category: :material)
end
let!(:child_a_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_a'), category: :material)
end
let!(:child_b_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_child_b'), category: :character)
end
let!(:grandchild_tag) do
Tag.create!(tag_name: TagName.create!(name: 'materials_grandchild'), category: :material)
end
let!(:root_material) do
create_material(root_tag, user: material_user, filename: 'root.png')
end
let!(:child_a_material) do
create_material(child_a_tag, user: material_user, filename: 'child_a.png')
end
let!(:grandchild_material) do
create_material(grandchild_tag, user: material_user, filename: 'grandchild.png')
end
before do
TagImplication.create!(parent_tag: root_tag, tag: child_b_tag)
TagImplication.create!(parent_tag: root_tag, tag: child_a_tag)
TagImplication.create!(parent_tag: child_a_tag, tag: grandchild_tag)
end
it 'returns a tag tree with nested materials sorted by child name' do
get "/tags/name/#{ CGI.escape(root_tag.name) }/materials"
expect(response).to have_http_status(:ok)
expect(json).to include(
'id' => root_tag.id,
'name' => 'materials_root',
'category' => 'material'
)
expect(json['material']).to be_present
expect(json.dig('material', 'id')).to eq(root_material.id)
expect(json.dig('material', 'file')).to be_present
expect(json.dig('material', 'content_type')).to eq('image/png')
expect(json['children'].map { |t| t['name'] }).to eq([
'materials_child_a',
'materials_child_b'
])
child_a = json['children'].find { |t| t['name'] == 'materials_child_a' }
child_b = json['children'].find { |t| t['name'] == 'materials_child_b' }
expect(child_a.dig('material', 'id')).to eq(child_a_material.id)
expect(child_a['children'].map { |t| t['name'] }).to eq(['materials_grandchild'])
expect(child_a.dig('children', 0, 'material', 'id')).to eq(grandchild_material.id)
expect(child_b['material']).to be_nil
expect(child_b['children']).to eq([])
end
it 'returns 404 when the tag does not exist' do
get '/tags/name/no_such_tag_12345/materials'
expect(response).to have_http_status(:not_found)
end
end
end end
@@ -1,150 +0,0 @@
require 'rails_helper'
RSpec.describe 'TheatreComments', type: :request do
def sign_in_as(user)
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
end
describe 'GET /theatres/:theatre_id/comments' do
let(:theatre) { create(:theatre) }
let(:other_theatre) { create(:theatre) }
let(:alice) { create(:user, name: 'Alice') }
let(:bob) { create(:user, name: 'Bob') }
let!(:comment_3) do
create(
:theatre_comment,
theatre: theatre,
no: 3,
user: alice,
content: 'third comment'
)
end
let!(:comment_1) do
create(
:theatre_comment,
theatre: theatre,
no: 1,
user: alice,
content: 'first comment'
)
end
let!(:comment_2) do
create(
:theatre_comment,
theatre: theatre,
no: 2,
user: bob,
content: 'second comment'
)
end
let!(:other_comment) do
create(
:theatre_comment,
theatre: other_theatre,
no: 1,
user: bob,
content: 'other theatre comment'
)
end
it 'theatre_id で絞り込み、no_gt より大きいものを no 降順で返す' do
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2])
expect(response.parsed_body.map { |row| row['content'] }).to eq([
'third comment',
'second comment'
])
end
it 'user は id と name だけを含む' do
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.first['user']).to eq({
'id' => alice.id,
'name' => 'Alice'
})
expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name')
end
it 'no_gt が負数なら 0 として扱う' do
get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 }
expect(response).to have_http_status(:ok)
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
end
end
describe 'POST /theatres/:theatre_id/comments' do
let(:user) { create(:user, name: 'Alice') }
let(:theatre) { create(:theatre, next_comment_no: 2) }
before do
create(
:theatre_comment,
theatre: theatre,
no: 1,
user: user,
content: 'existing comment'
)
end
it '未ログインなら 401 を返す' do
expect {
post "/theatres/#{theatre.id}/comments", params: { content: 'hello' }
}.not_to change(TheatreComment, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'content が blank なら 422 を返す' do
sign_in_as(user)
expect {
post "/theatres/#{theatre.id}/comments", params: { content: ' ' }
}.not_to change(TheatreComment, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
it 'theatre が存在しなければ 404 を返す' do
sign_in_as(user)
expect {
post '/theatres/999999/comments', params: { content: 'hello' }
}.not_to change(TheatreComment, :count)
expect(response).to have_http_status(:not_found)
end
it 'コメントを作成し、user を紐づけ、next_comment_no を進める' do
sign_in_as(user)
expect {
post "/theatres/#{theatre.id}/comments", params: { content: 'new comment' }
}.to change(TheatreComment, :count).by(1)
expect(response).to have_http_status(:created)
comment = TheatreComment.find_by!(theatre: theatre, no: 2)
expect(comment.user).to eq(user)
expect(comment.content).to eq('new comment')
expect(theatre.reload.next_comment_no).to eq(3)
expect(response.parsed_body.slice('theatre_id', 'no', 'user_id', 'content')).to eq({
'theatre_id' => theatre.id,
'no' => 2,
'user_id' => user.id,
'content' => 'new comment'
})
end
end
end
+2 -20
View File
@@ -117,18 +117,11 @@ RSpec.describe 'Theatres API', type: :request do
expect(theatre.host_user_id).to eq(member.id) expect(theatre.host_user_id).to eq(member.id)
expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now)
expect(json).to include( expect(json).to eq(
'host_flg' => true, 'host_flg' => true,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil 'post_started_at' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly(
{
'id' => member.id,
'name' => 'member user'
}
)
end end
end end
@@ -174,22 +167,11 @@ RSpec.describe 'Theatres API', type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(theatre.reload.host_user_id).to eq(other_user.id) expect(theatre.reload.host_user_id).to eq(other_user.id)
expect(json).to include( expect(json).to eq(
'host_flg' => false, 'host_flg' => false,
'post_id' => nil, 'post_id' => nil,
'post_started_at' => nil 'post_started_at' => nil
) )
expect(json.fetch('watching_users')).to contain_exactly(
{
'id' => member.id,
'name' => 'member user'
},
{
'id' => other_user.id,
'name' => 'other user'
}
)
end end
end end
-124
View File
@@ -90,128 +90,4 @@ RSpec.describe "nico:sync" do
expect(active_names).to include("nico:NEW") expect(active_names).to include("nico:NEW")
expect(active_names).not_to include("nico:OLD") expect(active_names).not_to include("nico:OLD")
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
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
end end
+179 -923
View File
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -15,8 +15,6 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/noto-sans-jp": "^5.2.9", "@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-dialog": "^1.1.14",
"@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-toast": "^1.2.14", "@radix-ui/react-toast": "^1.2.14",
@@ -39,15 +37,13 @@
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"unist-util-visit-parents": "^6.0.1", "zustand": "^5.0.8",
"zustand": "^5.0.8" "unist-util-visit-parents": "^6.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.25.0", "@eslint/js": "^9.25.0",
"@tailwindcss/typography": "^0.5.19",
"@types/axios": "^0.14.4", "@types/axios": "^0.14.4",
"@types/markdown-it": "^14.1.2", "@types/markdown-it": "^14.1.2",
"@types/mdx": "^2.0.13",
"@types/node": "^24.0.13", "@types/node": "^24.0.13",
"@types/react": "^19.1.2", "@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
+29 -47
View File
@@ -1,4 +1,4 @@
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion' import { AnimatePresence, LayoutGroup } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { BrowserRouter, import { BrowserRouter,
Navigate, Navigate,
@@ -10,15 +10,8 @@ import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
import TopNav from '@/components/TopNav' import TopNav from '@/components/TopNav'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import MaterialBasePage from '@/pages/materials/MaterialBasePage'
import MaterialDetailPage from '@/pages/materials/MaterialDetailPage'
import MaterialListPage from '@/pages/materials/MaterialListPage'
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
// import MaterialSearchPage from '@/pages/materials/MaterialSearchPage'
import MorePage from '@/pages/MorePage'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import TOSPage from '@/pages/TOSPage.mdx'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage' import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
@@ -46,36 +39,30 @@ const RouteTransitionWrapper = ({ user, setUser }: {
const location = useLocation () const location = useLocation ()
return ( return (
<AnimatePresence mode="wait"> <LayoutGroup id="gallery-shared">
<Routes location={location}> <AnimatePresence mode="wait">
<Route path="/" element={<Navigate to="/posts" replace/>}/> <Routes location={location} key={location.pathname}>
<Route path="/posts" element={<PostListPage/>}/> <Route path="/" element={<Navigate to="/posts" replace/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/search" element={<PostSearchPage/>}/> <Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/> <Route path="/posts/search" element={<PostSearchPage/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/> <Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
<Route path="/tags" element={<TagListPage/>}/> <Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags" element={<TagListPage/>}/>
<Route path="/theatres/:id" element={<TheatreDetailPage/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/materials" element={<MaterialBasePage/>}> <Route path="/theatres/:id" element={<TheatreDetailPage/>}/>
<Route index element={<MaterialListPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="new" element={<MaterialNewPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
<Route path=":id" element ={<MaterialDetailPage/>}/> <Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
</Route> <Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
{/* <Route path="/materials/search" element={<MaterialSearchPage/>}/> */} <Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/> <Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/> <Route path="*" element={<NotFound/>}/>
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/> </Routes>
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/> </AnimatePresence>
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/> </LayoutGroup>)
<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>)
} }
@@ -133,15 +120,10 @@ export default (() => {
<> <>
<RouteBlockerOverlay/> <RouteBlockerOverlay/>
<BrowserRouter> <BrowserRouter>
<LayoutGroup> <div className="flex flex-col h-screen w-screen">
<motion.div <TopNav user={user}/>
layout="position" <RouteTransitionWrapper user={user} setUser={setUser}/>
transition={{ layout: { duration: .2, ease: 'easeOut' } }} </div>
className="flex flex-col h-dvh w-full overflow-y-hidden">
<TopNav user={user}/>
<RouteTransitionWrapper user={user} setUser={setUser}/>
</motion.div>
</LayoutGroup>
<Toaster/> <Toaster/>
</BrowserRouter> </BrowserRouter>
</>) </>)
@@ -1,97 +0,0 @@
import { Fragment, useEffect, useState } from 'react'
import TagLink from '@/components/TagLink'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { apiGet } from '@/lib/api'
import type { FC, ReactNode } from 'react'
import type { Tag } from '@/types'
type TagWithDepth = Tag & {
hasChildren: boolean
children: TagWithDepth[] }
const setChildrenById = (
tags: TagWithDepth[],
targetId: number,
children: TagWithDepth[],
): TagWithDepth[] => (
tags.map (tag => {
if (tag.id === targetId)
return { ...tag, children }
if (tag.children.length === 0)
return tag
return { ...tag,
children: (setChildrenById (tag.children, targetId, children)
.filter (t => t.category !== 'meme' || t.hasChildren)) }
}))
export default (() => {
const [tags, setTags] = useState<TagWithDepth[]> ([])
const [openTags, setOpenTags] = useState<Record<number, boolean>> ({ })
const [tagFetchedFlags, setTagFetchedFlags] = useState<Record<number, boolean>> ({ })
useEffect (() => {
void (async () => {
setTags ((await apiGet<TagWithDepth[]> ('/tags/with-depth'))
.filter (t => t.category !== 'meme' || t.hasChildren))
}) ()
}, [])
const renderTags = (ts: TagWithDepth[], nestLevel = 0): ReactNode => (
ts.map (t => (
<Fragment key={t.id}>
<li>
<div className="flex">
<div className="flex-none w-4">
{t.hasChildren && (
<a
href="#"
onClick={async e => {
e.preventDefault ()
if (!(tagFetchedFlags[t.id]))
{
try
{
const data =
await apiGet<TagWithDepth[]> (
'/tags/with-depth', { params: { parent: String (t.id) } })
setTags (prev => setChildrenById (prev, t.id, data))
setTagFetchedFlags (prev => ({ ...prev, [t.id]: true }))
}
catch
{
;
}
}
setOpenTags (prev => ({ ...prev, [t.id]: !(prev[t.id]) }))
}}>
{openTags[t.id] ? <>&minus;</> : '+'}
</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
+1 -1
View File
@@ -56,7 +56,7 @@ export default (({ posts, onClick }: Props) => {
cardRef.current.style.zIndex = '' cardRef.current.style.zIndex = ''
cardRef.current.style.position = '' cardRef.current.style.position = ''
}} }}
transition={{ layout: { duration: .2, ease: 'easeOut' } }}> transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}>
<img src={post.thumbnail || post.thumbnailBase || undefined} <img src={post.thumbnail || post.thumbnailBase || undefined}
alt={post.title || post.url} alt={post.title || post.url}
title={post.title || post.url || undefined} title={post.title || post.url || undefined}
+1 -2
View File
@@ -71,8 +71,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|| ev.metaKey || ev.metaKey
|| ev.ctrlKey || ev.ctrlKey
|| ev.shiftKey || ev.shiftKey
|| ev.altKey || ev.altKey)
|| (rest.target && rest.target !== '_self'))
return return
ev.preventDefault () ev.preventDefault ()
+59 -38
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from 'react'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
import { apiGet } from '@/lib/api'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { ComponentProps, FC, HTMLAttributes } from 'react' import type { ComponentProps, FC, HTMLAttributes } from 'react'
@@ -10,7 +13,8 @@ type CommonProps = {
tag: Tag tag: Tag
nestLevel?: number nestLevel?: number
withWiki?: boolean withWiki?: boolean
withCount?: boolean } withCount?: boolean
prefetch?: boolean }
type PropsWithLink = type PropsWithLink =
& CommonProps & CommonProps
@@ -32,7 +36,37 @@ export default (({ tag,
linkFlg = true, linkFlg = true,
withWiki = true, withWiki = true,
withCount = true, withCount = true,
prefetch = false,
...props }: Props) => { ...props }: Props) => {
const [havingWiki, setHavingWiki] = useState (true)
const wikiExists = async (tag: Tag) => {
if ('hasWiki' in tag)
{
setHavingWiki (tag.hasWiki)
return
}
const tagName = (tag as Tag).name
try
{
await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`)
setHavingWiki (true)
}
catch
{
setHavingWiki (false)
}
}
useEffect (() => {
if (!(linkFlg) || !(withWiki))
return
wikiExists (tag)
}, [tag.name, linkFlg, withWiki])
const spanClass = cn ( const spanClass = cn (
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`, `text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
`dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`) `dark:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE }`)
@@ -45,39 +79,19 @@ export default (({ tag,
<> <>
{(linkFlg && withWiki) && ( {(linkFlg && withWiki) && (
<span className="mr-1"> <span className="mr-1">
{(tag.materialId != null || tag.hasWiki) {havingWiki
? ( ? (
tag.materialId == null <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
? ( className={linkClass}>
<PrefetchLink ?
to={`/wiki/${ encodeURIComponent (tag.name) }`} </PrefetchLink>)
className={linkClass}>
?
</PrefetchLink>)
: (
<PrefetchLink
to={`/materials/${ tag.materialId }`}
className={linkClass}>
?
</PrefetchLink>))
: ( : (
['character', 'material'].includes (tag.category) <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`}
? ( className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
<PrefetchLink dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`} title={`${ tag.name } Wiki が存在しません.`}>
className="animate-[wiki-blink_.25s_steps(2,end)_infinite] !
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" </PrefetchLink>)}
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>)} </span>)}
{nestLevel > 0 && ( {nestLevel > 0 && (
<span <span
@@ -94,12 +108,19 @@ export default (({ tag,
</>)} </>)}
{linkFlg {linkFlg
? ( ? (
<PrefetchLink prefetch
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} ? <PrefetchLink
className={linkClass} to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
{...props}> className={linkClass}
{tag.name} {...props}>
</PrefetchLink>) {tag.name}
</PrefetchLink>
: <PrefetchLink
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
className={linkClass}
{...props}>
{tag.name}
</PrefetchLink>)
: ( : (
<span className={spanClass} <span className={spanClass}
{...props}> {...props}>
+2 -4
View File
@@ -65,10 +65,8 @@ export default (({ posts, onClick }: Props) => {
{CATEGORIES.flatMap (cat => cat in tags ? ( {CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => ( tags[cat].map (tag => (
<li key={tag.id} className="mb-1"> <li key={tag.id} className="mb-1">
<motion.div <motion.div layoutId={`tag-${ tag.id }`}>
transition={{ layout: { duration: .2, ease: 'easeOut' } }} <TagLink tag={tag} prefetch onClick={onClick}/>
layoutId={`tag-${ tag.id }`}>
<TagLink tag={tag} onClick={onClick}/>
</motion.div> </motion.div>
</li>))) : [])} </li>))) : [])}
</ul> </ul>
+98 -236
View File
@@ -14,65 +14,11 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react' import type { FC, MouseEvent } from 'react'
import type { Menu, MenuVisibleItem, Tag, User } from '@/types' import type { Menu, User } from '@/types'
type Props = { user: User | null } 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: <>&thinsp;1&thinsp;</>, to: '/theatres/1' },
{ name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' },
{ name: <>&thinsp;1&thinsp;</>,
to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] },
{ name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [
{ name: '検索', 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) => { export default (({ user }: Props) => {
const location = useLocation () const location = useLocation ()
@@ -80,30 +26,25 @@ export default (({ user }: Props) => {
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([]) const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null) const navRef = useRef<HTMLDivElement | null> (null)
const measure = (idx: number) => { const measure = () => {
const nav = navRef.current const nav = navRef.current
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx] const el = itemsRef.current[activeIdx]
if (!(nav) || !(el) || activeIdx < 0)
if (!(nav) || !(el)) return
{
setHL ({ left: 0, width: 0, visible: true })
return
}
const navRect = nav.getBoundingClientRect () const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect () const elRect = el.getBoundingClientRect ()
setHL ({ left: elRect.left - navRect.left, setHl ({ left: elRect.left - navRect.left,
width: elRect.width, width: elRect.width,
visible: true }) visible: true })
} }
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({ const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0, left: 0,
width: 0, width: 0,
visible: false }) visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
@@ -121,10 +62,42 @@ export default (({ user }: Props) => {
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle) })
const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const postCount = tag?.postCount ?? 0
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
const activeIdx = const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId)
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) 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 prevActiveIdxRef = useRef<number> (activeIdx) const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -137,31 +110,35 @@ export default (({ user }: Props) => {
const dir = dirRef.current const dir = dirRef.current
useLayoutEffect (() => { useLayoutEffect (() => {
const raf = requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) if (activeIdx < 0)
const onResize = () => requestAnimationFrame (() => measure (moreVsbl ? -1 : activeIdx)) return
const raf = requestAnimationFrame (measure)
const onResize = () => requestAnimationFrame (measure)
addEventListener ('resize', onResize) addEventListener ('resize', onResize)
return () => { return () => {
cancelAnimationFrame (raf) cancelAnimationFrame (raf)
removeEventListener ('resize', onResize) removeEventListener ('resize', onResize)
} }
}) }, [activeIdx])
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe () return () => unsubscribe ()
}, [activeIdx]) }, [])
useEffect (() => { useEffect (() => {
setMenuOpen (false) setMenuOpen (false)
setOpenItemIdx (activeIdx) setOpenItemIdx (menu.findIndex (item => (
location.pathname.startsWith (item.base || item.to))))
}, [location]) }, [location])
return ( return (
<> <>
<nav className="px-3 flex justify-between items-center w-full <nav className="px-3 flex justify-between items-center w-full min-h-[48px]
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50"> bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-12"> <div className="flex items-center gap-2 h-full">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400
@@ -172,48 +149,26 @@ export default (({ user }: Props) => {
</PrefetchLink> </PrefetchLink>
<div ref={navRef} className="relative hidden md:flex h-12 items-center"> <div ref={navRef} className="relative hidden md:flex h-full items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
'bg-yellow-200 dark:bg-red-950', 'bg-yellow-200 dark:bg-red-950',
'transition-[transform,width] duration-200 ease-out')} 'transition-[transform,width] duration-200 ease-out')}
style={{ width: hl.width, style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`, transform: `translate(${ hl.left }px, -50%)`,
opacity: hl.visible ? 1 : 0 }}/> opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => ( {menu.map ((item, i) => (
<motion.div <PrefetchLink
key={item.to} key={i}
layoutId={`menu-${ item.name }`} to={item.to}
animate={{ opacity: moreVsbl ? 0 : 1 }} ref={(el: (HTMLAnchorElement | null)) => {
transition={{ opacity: { duration: .12 }, itemsRef.current[i] = el
layout: { duration: .2, ease: 'easeOut' } }} }}
style={{ pointerEvents: moreVsbl ? 'none' : 'auto' }} className={cn ('relative z-10 flex h-full items-center px-5',
onMouseEnter={() => setMoreVsbl (false)}> (i === openItemIdx) && 'font-bold')}>
<PrefetchLink {item.name}
to={item.to} </PrefetchLink>))}
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')}>
&raquo;
</PrefetchLink>
</div> </div>
</div> </div>
@@ -231,115 +186,35 @@ export default (({ user }: Props) => {
</a> </a>
</nav> </nav>
<AnimatePresence initial={false}> <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
<motion.div items-center w-full min-h-[40px] overflow-hidden">
key="submenu-shell" <AnimatePresence initial={false} custom={dir}>
layout <motion.div
className="relative hidden md:block overflow-hidden key={activeIdx}
bg-yellow-200 dark:bg-red-950" custom={dir}
style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }} variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
onMouseLeave={() => { centre: { y: 0, opacity: 1 },
if (moreVsbl) exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
setMoreVsbl (false) className="absolute inset-0 flex items-center px-3"
}} initial="enter"
transition={{ layout: { duration: .2, ease: 'easeOut' } }} animate="centre"
onAnimationComplete={() => { exit="exit"
measure (moreVsbl ? -1 : activeIdx) transition={{ duration: .2, ease: 'easeOut' }}>
}}> {(menu[activeIdx]?.subMenu ?? [])
{moreVsbl .filter (item => item.visible ?? true)
? ( .map ((item, i) => (
menu.map ((item, i) => ( 'component' in item
<div key={i} className="relative h-[40px]"> ? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
<div className="absolute inset-0 flex items-center px-3"> : (
<motion.div <PrefetchLink
transition={{ duration: .2, ease: 'easeOut' }} key={`l-${ i }`}
{...((item.visible ?? true) to={item.to}
? { layoutId: `menu-${ item.name }` } className="h-full flex items-center px-3">
: { initial: { x: 40, y: -40, opacity: 0 }, {item.name}
animate: { x: 0, y: 0, opacity: 1 }, </PrefetchLink>)))}
exit: { x: 40, y: -40, opacity: 0 } })} </motion.div>
className="z-10 h-full flex items-center px-3 font-bold w-24"> </AnimatePresence>
<h2>{item.name}</h2> </div>
</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}> <AnimatePresence initial={false}>
{menuOpen && ( {menuOpen && (
@@ -356,7 +231,7 @@ export default (({ user }: Props) => {
exit="closed" exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}> transition={{ duration: .2, ease: 'easeOut' }}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {menu.map ((item, i) => (
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
@@ -400,25 +275,12 @@ export default (({ user }: Props) => {
<PrefetchLink <PrefetchLink
key={`sp-l-${ i }-${ j }`} key={`sp-l-${ i }-${ j }`}
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//'
? '_blank'
: undefined}
className="w-full min-h-[36px] flex items-center pl-12"> className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</motion.div>)} </motion.div>)}
</AnimatePresence> </AnimatePresence>
</Fragment>))} </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'))}>
&raquo;
</PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
<Separator/> <Separator/>
</motion.div>)} </motion.div>)}
+15 -9
View File
@@ -4,6 +4,8 @@ import ReactMarkdown from 'react-markdown'
import remarkGFM from 'remark-gfm' import remarkGFM from 'remark-gfm'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import { wikiKeys } from '@/lib/queryKeys' import { wikiKeys } from '@/lib/queryKeys'
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
import { fetchWikiPages } from '@/lib/wiki' import { fetchWikiPages } from '@/lib/wiki'
@@ -14,15 +16,19 @@ import type { Components } from 'react-markdown'
type Props = { title: string type Props = { title: string
body?: string } body?: string }
const mdComponents = { a: (({ href, children }) => ( const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>,
['/', '.'].some (e => href?.startsWith (e)) h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>,
? <PrefetchLink to={href!}>{children}</PrefetchLink> ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>,
: ( ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>,
<a href={href} a: (({ href, children }) => (
target="_blank" ['/', '.'].some (e => href?.startsWith (e))
rel="noopener noreferrer"> ? <PrefetchLink to={href!}>{children}</PrefetchLink>
{children} : (
</a>))) } as const satisfies Components <a href={href}
target="_blank"
rel="noopener noreferrer">
{children}
</a>))) } as const satisfies Components
export default (({ title, body }: Props) => { export default (({ title, body }: Props) => {
@@ -1,11 +1,9 @@
import { cn } from '@/lib/utils' import React from 'react'
import type { ComponentPropsWithoutRef, FC } from 'react' type Props = { children: React.ReactNode }
type Props = ComponentPropsWithoutRef<'h2'>
export default (({ children, className, ...rest }: Props) => ( export default ({ children }: Props) => (
<h2 {...rest} className={cn ('text-xl my-4', className)}> <h2 className="text-xl my-4">
{children} {children}
</h2>)) satisfies FC<Props> </h2>)
@@ -1,97 +0,0 @@
import { useState } from 'react'
import TagSearchBox from '@/components/TagSearchBox'
import { apiGet } from '@/lib/api'
import type { FC, ChangeEvent, KeyboardEvent } from 'react'
import type { Tag } from '@/types'
type Props = {
value: string
setValue: (value: string) => void }
export default (({ value, setValue }: Props) => {
const [activeIndex, setActiveIndex] = useState (-1)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setValue (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = value?.split (' ')
parts[parts.length - 1] = tag.name
setValue (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}
return (
<div className="relative">
<input
type="text"
value={value}
onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
</div>)
}) satisfies FC<Props>
+2 -7
View File
@@ -1,5 +1,3 @@
import { motion } from 'framer-motion'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react' import type { FC, ReactNode } from 'react'
@@ -10,9 +8,6 @@ type Props = {
export default (({ children, className }: Props) => ( export default (({ children, className }: Props) => (
<motion.main <main className={cn ('flex-1 overflow-y-auto p-4', className)}>
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout="position">
{children} {children}
</motion.main>)) satisfies FC<Props> </main>)) satisfies FC<Props>
@@ -1,30 +1,9 @@
import { motion } from 'framer-motion' import React from 'react'
import { Helmet } from 'react-helmet-async'
import type { FC, ReactNode } from 'react' type Props = { children: React.ReactNode }
type Props = { children: ReactNode }
export default (({ children }: Props) => ( export default ({ children }: Props) => (
<motion.div <div className="p-4 w-full md:w-64 md:h-full">
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} {children}
</motion.div>)) satisfies FC<Props> </div>)
+3 -5
View File
@@ -46,12 +46,10 @@ a
body body
{ {
margin: 0; margin: 0;
display: flex;
place-items: center;
min-width: 320px; min-width: 320px;
} min-height: 100vh;
#root
{
min-height: 100dvh;
} }
h1 h1
-4
View File
@@ -1,4 +0,0 @@
import type { MDXComponents } from 'mdx/types'
export const useMDXComponents = (): MDXComponents => ({ })
-46
View File
@@ -1,46 +0,0 @@
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
-134
View File
@@ -1,134 +0,0 @@
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>
@@ -1,12 +0,0 @@
import { Outlet } from 'react-router-dom'
import MaterialSidebar from '@/components/MaterialSidebar'
import type { FC } from 'react'
export default (() => (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden">
<MaterialSidebar/>
<Outlet/>
</div>)) satisfies FC
@@ -1,182 +0,0 @@
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiGet, apiPut } from '@/lib/api'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
type MaterialWithTag = Material & { tag: Tag }
export default (() => {
const { id } = useParams ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [loading, setLoading] = useState (false)
const [material, setMaterial] = useState<MaterialWithTag | null> (null)
const [sending, setSending] = useState (false)
const [tag, setTag] = useState ('')
const [url, setURL] = useState ('')
const handleSubmit = async () => {
const formData = new FormData
if (tag.trim ())
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url.trim ())
formData.append ('url', url)
try
{
setSending (true)
const data = await apiPut<Material> (`/materials/${ id }`, formData)
setMaterial (data)
toast ({ title: '更新成功!' })
}
catch
{
toast ({ title: '更新失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
useEffect (() => {
if (!(id))
return
void (async () => {
try
{
setLoading (true)
const data = await apiGet<MaterialWithTag> (`/materials/${ id }`)
setMaterial (data)
setTag (data.tag.name)
if (data.file && data.contentType)
{
setFilePreview (data.file)
setFile (new File ([await (await fetch (data.file)).blob ()],
data.file,
{ type: data.contentType }))
}
setURL (data.url ?? '')
}
finally
{
setLoading (false)
}
}) ()
}, [id])
return (
<MainArea>
{material && (
<Helmet>
<title>{`${ material.tag.name } 素材照会 | ${ SITE_TITLE }`}</title>
</Helmet>)}
{loading ? 'Loading...' : (material && (
<>
<PageTitle>
<TagLink
tag={material.tag}
withWiki={false}
withCount={false}/>
</PageTitle>
{(material.file && material.contentType) && (
(/image\/.*/.test (material.contentType) && (
<img src={material.file} alt={material.tag.name || undefined}/>))
|| (/video\/.*/.test (material.contentType) && (
<video src={material.file} controls/>))
|| (/audio\/.*/.test (material.contentType) && (
<audio src={material.file} controls/>)))}
<TabGroup>
<Tab name="Wiki">
<WikiBody
title={material.tag.name}
body={material.wikiPageBody ?? undefined}/>
</Tab>
<Tab name="編輯">
<div className="max-w-wl pt-2 space-y-4">
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
</div>
</Tab>
</TabGroup>
</>))}
</MainArea>)
}) satisfies FC
@@ -1,166 +0,0 @@
import { Fragment, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import nikumaru from '@/assets/fonts/nikumaru.otf'
import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import SectionTitle from '@/components/common/SectionTitle'
import SubsectionTitle from '@/components/common/SubsectionTitle'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import type { FC } from 'react'
import type { Material, Tag } from '@/types'
type TagWithMaterial = Omit<Tag, 'children'> & {
children: TagWithMaterial[]
material: Material | null }
const MaterialCard = ({ tag }: { tag: TagWithMaterial }) => {
if (!(tag.material))
return
return (
<PrefetchLink
to={`/materials/${ tag.material.id }`}
className="block w-40 h-40">
<div
className="w-full h-full overflow-hidden rounded-xl shadow
text-center content-center text-4xl"
style={{ fontFamily: 'Nikumaru' }}>
{(tag.material.contentType && /image\/.*/.test (tag.material.contentType))
? <img src={tag.material.file || undefined}/>
: <span></span>}
</div>
</PrefetchLink>)
}
export default (() => {
const [loading, setLoading] = useState (false)
const [tag, setTag] = useState<TagWithMaterial | null> (null)
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
useEffect (() => {
if (!(tagQuery))
{
setTag (null)
return
}
void (async () => {
try
{
setLoading (true)
setTag (
await apiGet<TagWithMaterial> (
`/tags/name/${ encodeURIComponent (tagQuery) }/materials`))
}
finally
{
setLoading (false)
}
}) ()
}, [location.search])
return (
<MainArea>
<Helmet>
<style>
{`
@font-face
{
font-family: 'Nikumaru';
src: url(${ nikumaru }) format('opentype');
}`}
</style>
<title>{`${ tag ? `${ tag.name } 素材集` : '素材集' } | ${ SITE_TITLE }`}</title>
</Helmet>
{loading ? 'Loading...' : (
tag
? (
<>
<PageTitle>
<TagLink
tag={tag}
withWiki={false}
withCount={false}
to={tag.material
? `/materials/${ tag.material.id }`
: `/materials?tag=${ encodeURIComponent (tag.name) }`}/>
</PageTitle>
{(!(tag.material) && tag.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={tag}/>
<div className="ml-2">
{tag.children.map (c2 => (
<Fragment key={c2.id}>
<SectionTitle>
<TagLink
tag={c2}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c2.name) }`}/>
</SectionTitle>
{(!(c2.material) && c2.category !== 'meme') && (
<div className="-mt-4">
<PrefetchLink
to={`/materials/new?tag=${ encodeURIComponent (c2.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c2}/>
<div className="ml-2">
{c2.children.map (c3 => (
<Fragment key={c3.id}>
<SubsectionTitle>
<TagLink
tag={c3}
withWiki={false}
withCount={false}
to={`/materials?tag=${ encodeURIComponent (c3.name) }`}/>
</SubsectionTitle>
{(!(c3.material) && c3.category !== 'meme') && (
<div className="-mt-2">
<PrefetchLink
to={`/materials/new?tag=${
encodeURIComponent (c3.name) }`}>
</PrefetchLink>
</div>)}
<MaterialCard tag={c3}/>
</Fragment>))}
</div>
</Fragment>))}
</div>
</>)
: (
<>
<p></p>
<p></p>
<ul>
<li><PrefetchLink to="/materials/new"></PrefetchLink></li>
{/* <li><a href="#">すべての素材をダウンロードする</a></li> */}
</ul>
</>))}
</MainArea>)
}) satisfies FC
@@ -1,124 +0,0 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation, useNavigate } from 'react-router-dom'
import Form from '@/components/common/Form'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { SITE_TITLE } from '@/config'
import { apiPost } from '@/lib/api'
import type { FC } from 'react'
export default (() => {
const location = useLocation ()
const query = new URLSearchParams (location.search)
const tagQuery = query.get ('tag') ?? ''
const navigate = useNavigate ()
const [file, setFile] = useState<File | null> (null)
const [filePreview, setFilePreview] = useState ('')
const [sending, setSending] = useState (false)
const [tag, setTag] = useState (tagQuery)
const [url, setURL] = useState ('')
const handleSubmit = async () => {
const formData = new FormData
if (tag)
formData.append ('tag', tag)
if (file)
formData.append ('file', file)
if (url)
formData.append ('url', url)
try
{
setSending (true)
await apiPost ('/materials', formData)
toast ({ title: '送信成功!' })
navigate (`/materials?tag=${ encodeURIComponent (tag) }`)
}
catch
{
toast ({ title: '送信失敗……', description: '入力を見直してください.' })
}
finally
{
setSending (false)
}
}
return (
<MainArea>
<Helmet>
<title>{`素材追加 | ${ SITE_TITLE }`}</title>
</Helmet>
<Form>
<PageTitle></PageTitle>
{/* タグ */}
<div>
<Label></Label>
<TagInput value={tag} setValue={setTag}/>
</div>
{/* ファイル */}
<div>
<Label></Label>
<input
type="file"
accept="image/*,video/*,audio/*"
onChange={e => {
const f = e.target.files?.[0]
setFile (f ?? null)
setFilePreview (f ? URL.createObjectURL (f) : '')
}}/>
{(file && filePreview) && (
(/image\/.*/.test (file.type) && (
<img
src={filePreview}
alt="preview"
className="mt-2 max-h-48 rounded border"/>))
|| (/video\/.*/.test (file.type) && (
<video
src={filePreview}
controls
className="mt-2 max-h-48 rounded border"/>))
|| (/audio\/.*/.test (file.type) && (
<audio
src={filePreview}
controls
className="mt-2 max-h-48"/>))
|| (
<p className="text-red-600 dark:text-red-400">
</p>))}
</div>
{/* 参考 URL */}
<div>
<Label> URL</Label>
<input
type="url"
value={url}
onChange={e => setURL (e.target.value)}
className="w-full border p-2 rounded"/>
</div>
{/* 送信 */}
<Button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
disabled={sending}>
</Button>
</Form>
</MainArea>)
}) satisfies FC
@@ -1,49 +0,0 @@
import { useState } from 'react'
import { Helmet } from 'react-helmet-async'
import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config'
import type { FC, FormEvent } from 'react'
export default (() => {
const [tagName, setTagName] = useState ('')
const [parentTagName, setParentTagName] = useState ('')
const handleSearch = (e: FormEvent) => {
e.preventDefault ()
}
return (
<MainArea>
<Helmet>
<title> | {SITE_TITLE}</title>
</Helmet>
<div className="max-w-xl">
<PageTitle></PageTitle>
<form onSubmit={handleSearch} className="space-y-2">
{/* タグ */}
<div>
<Label></Label>
<TagInput
value={tagName}
setValue={setTagName}/>
</div>
{/* 親タグ */}
<div>
<Label></Label>
<TagInput
value={parentTagName}
setValue={setParentTagName}/>
</div>
</form>
</div>
</MainArea>)
}) satisfies FC
+1 -1
View File
@@ -93,7 +93,7 @@ export default (({ user }: Props) => {
: 'bg-gray-500 hover:bg-gray-600') : 'bg-gray-500 hover:bg-gray-600')
return ( return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> <div className="md:flex md:flex-1">
<Helmet> <Helmet>
{(post?.thumbnail || post?.thumbnailBase) && ( {(post?.thumbnail || post?.thumbnailBase) && (
<meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)}
+1 -3
View File
@@ -69,9 +69,7 @@ export default (() => {
}, [location.search]) }, [location.search])
return ( return (
<div <div className="md:flex md:flex-1" ref={containerRef}>
className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"
ref={containerRef}>
<Helmet> <Helmet>
<title> <title>
{tags.length {tags.length
+82 -6
View File
@@ -7,22 +7,24 @@ import { useLocation, useNavigate } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import SortHeader from '@/components/SortHeader' import SortHeader from '@/components/SortHeader'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import TagSearchBox from '@/components/TagSearchBox'
import DateTimeField from '@/components/common/DateTimeField' import DateTimeField from '@/components/common/DateTimeField'
import Label from '@/components/common/Label' import Label from '@/components/common/Label'
import PageTitle from '@/components/common/PageTitle' import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination' import Pagination from '@/components/common/Pagination'
import TagInput from '@/components/common/TagInput'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet } from '@/lib/api'
import { fetchPosts } from '@/lib/posts' import { fetchPosts } from '@/lib/posts'
import { postsKeys } from '@/lib/queryKeys' import { postsKeys } from '@/lib/queryKeys'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { FC, FormEvent } from 'react' import type { FC, ChangeEvent, FormEvent, KeyboardEvent } from 'react'
import type { FetchPostsOrder, import type { FetchPostsOrder,
FetchPostsOrderField, FetchPostsOrderField,
FetchPostsParams } from '@/types' FetchPostsParams,
Tag } from '@/types'
const setIf = (qs: URLSearchParams, k: string, v: string | null) => { const setIf = (qs: URLSearchParams, k: string, v: string | null) => {
@@ -55,11 +57,14 @@ export default (() => {
const qUpdatedTo = query.get ('updated_to') ?? '' const qUpdatedTo = query.get ('updated_to') ?? ''
const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder const order = (query.get ('order') || 'original_created_at:desc') as FetchPostsOrder
const [activeIndex, setActiveIndex] = useState (-1)
const [createdFrom, setCreatedFrom] = useState<string | null> (null) const [createdFrom, setCreatedFrom] = useState<string | null> (null)
const [createdTo, setCreatedTo] = useState<string | null> (null) const [createdTo, setCreatedTo] = useState<string | null> (null)
const [matchType, setMatchType] = useState<'all' | 'any'> ('all') const [matchType, setMatchType] = useState<'all' | 'any'> ('all')
const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null)
const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null) const [originalCreatedTo, setOriginalCreatedTo] = useState<string | null> (null)
const [suggestions, setSuggestions] = useState<Tag[]> ([])
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
const [tagsStr, setTagsStr] = useState ('') const [tagsStr, setTagsStr] = useState ('')
const [title, setTitle] = useState ('') const [title, setTitle] = useState ('')
const [updatedFrom, setUpdatedFrom] = useState<string | null> (null) const [updatedFrom, setUpdatedFrom] = useState<string | null> (null)
@@ -98,6 +103,58 @@ export default (() => {
document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' })
}, [location.search]) }, [location.search])
// TODO: TagSearch からのコピペのため,共通化を考へる.
const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => {
setTagsStr (ev.target.value)
const q = ev.target.value.trim ().split (' ').at (-1)
if (!(q))
{
setSuggestions ([])
return
}
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
setSuggestions (data.filter (t => t.postCount > 0))
if (suggestions.length > 0)
setSuggestionsVsbl (true)
}
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
switch (ev.key)
{
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
break
case 'Enter':
if (activeIndex < 0)
break
ev.preventDefault ()
const selected = suggestions[activeIndex]
selected && handleTagSelect (selected)
break
case 'Escape':
ev.preventDefault ()
setSuggestionsVsbl (false)
break
}
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
{
setSuggestionsVsbl (false)
}
}
const search = async () => { const search = async () => {
const qs = new URLSearchParams () const qs = new URLSearchParams ()
setIf (qs, 'tags', tagsStr) setIf (qs, 'tags', tagsStr)
@@ -115,6 +172,15 @@ export default (() => {
navigate (`${ location.pathname }?${ qs.toString () }`) navigate (`${ location.pathname }?${ qs.toString () }`)
} }
// TODO: TagSearch からのコピペのため,共通化を考へる.
const handleTagSelect = (tag: Tag) => {
const parts = tagsStr.split (' ')
parts[parts.length - 1] = tag.name
setTagsStr (parts.join (' ') + ' ')
setSuggestions ([])
setActiveIndex (-1)
}
const handleSearch = (e: FormEvent) => { const handleSearch = (e: FormEvent) => {
e.preventDefault () e.preventDefault ()
search () search ()
@@ -157,11 +223,21 @@ export default (() => {
</div> </div>
{/* タグ */} {/* タグ */}
<div> <div className="relative">
<Label></Label> <Label></Label>
<TagInput <input
type="text"
value={tagsStr} value={tagsStr}
setValue={setTagsStr}/> onChange={whenChanged}
onFocus={() => setSuggestionsVsbl (true)}
onBlur={() => setSuggestionsVsbl (false)}
onKeyDown={handleKeyDown}
className="w-full border p-2 rounded"/>
<TagSearchBox
suggestions={
suggestionsVsbl && suggestions.length > 0 ? suggestions : [] as Tag[]}
activeIndex={activeIndex}
onSelect={handleTagSelect}/>
<fieldset className="w-full my-2"> <fieldset className="w-full my-2">
<label></label> <label></label>
<label className="mx-2"> <label className="mx-2">
+44 -271
View File
@@ -2,225 +2,85 @@ import { useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async' import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import ErrorScreen from '@/components/ErrorScreen'
import PostEmbed from '@/components/PostEmbed' import PostEmbed from '@/components/PostEmbed'
import PrefetchLink from '@/components/PrefetchLink'
import TagDetailSidebar from '@/components/TagDetailSidebar'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' import { apiGet, apiPatch, apiPut } from '@/lib/api'
import { fetchPost } from '@/lib/posts' import { fetchPost } from '@/lib/posts'
import { dateString } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
import type { NiconicoMetadata, import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types'
NiconicoViewerHandle,
Post,
Theatre,
TheatreComment } from '@/types'
type TheatreInfo = { type TheatreInfo = {
hostFlg: boolean hostFlg: boolean
postId: number | null postId: number | null
postStartedAt: string | null postStartedAt: string | null }
watchingUsers: { id: number; name: string }[] }
const INITIAL_THEATRE_INFO =
{ hostFlg: false,
postId: null,
postStartedAt: null,
watchingUsers: [] as { id: number; name: string }[] } as const
export default (() => { export default (() => {
const { id } = useParams () const { id } = useParams ()
const commentsRef = useRef<HTMLDivElement> (null)
const embedRef = useRef<NiconicoViewerHandle> (null) const embedRef = useRef<NiconicoViewerHandle> (null)
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
const videoLengthRef = useRef (0)
const lastCommentNoRef = useRef (0)
const [comments, setComments] = useState<TheatreComment[]> ([])
const [content, setContent] = useState ('')
const [loading, setLoading] = useState (false) const [loading, setLoading] = useState (false)
const [sending, setSending] = useState (false)
const [status, setStatus] = useState (200)
const [theatre, setTheatre] = useState<Theatre | null> (null) const [theatre, setTheatre] = useState<Theatre | null> (null)
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) const [theatreInfo, setTheatreInfo] =
useState<TheatreInfo> ({ hostFlg: false, postId: null, postStartedAt: null })
const [post, setPost] = useState<Post | null> (null) const [post, setPost] = useState<Post | null> (null)
const [videoLength, setVideoLength] = useState (0) const [videoLength, setVideoLength] = useState (9_999_999_999)
useEffect (() => {
theatreInfoRef.current = theatreInfo
}, [theatreInfo])
useEffect (() => {
videoLengthRef.current = videoLength
}, [videoLength])
useEffect (() => {
lastCommentNoRef.current = comments[0]?.no ?? 0
}, [comments])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(id))
return return
let cancelled = false
setComments ([])
setTheatre (null)
setPost (null)
setTheatreInfo (INITIAL_THEATRE_INFO)
setVideoLength (0)
lastCommentNoRef.current = 0
void (async () => { void (async () => {
try setTheatre (await apiGet<Theatre> (`/theatres/${ id }`))
{
const data = await apiGet<Theatre> (`/theatres/${ id }`)
if (!(cancelled))
setTheatre (data)
}
catch (error)
{
setStatus ((error as any)?.response.status ?? 200)
}
}) () }) ()
return () => { const interval = setInterval (async () => {
cancelled = true if (theatreInfo.hostFlg
} && theatreInfo.postStartedAt
}, [id]) && ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()
> videoLength))
setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null })
else
setTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
}, 1_000)
return () => clearInterval (interval)
}, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength])
useEffect (() => { useEffect (() => {
if (!(id)) if (!(theatreInfo.hostFlg) || loading)
return return
let cancelled = false if (theatreInfo.postId == null)
let running = false
const tick = async () => {
if (running)
return
running = true
try
{ {
const newComments = await apiGet<TheatreComment[]> ( void (async () => {
`/theatres/${ id }/comments`, setLoading (true)
{ params: { no_gt: lastCommentNoRef.current } }) await apiPatch<void> (`/theatres/${ id }/next_post`)
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) setLoading (false)
}) ()
return
} }
}) () }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId])
return () => {
cancelled = true
}
}, [id, theatreInfo.hostFlg, theatreInfo.postId])
useEffect (() => { useEffect (() => {
setVideoLength (0)
if (theatreInfo.postId == null) if (theatreInfo.postId == null)
return return
let cancelled = false
void (async () => { void (async () => {
try setPost (await fetchPost (String (theatreInfo.postId)))
{
const nextPost = await fetchPost (String (theatreInfo.postId))
if (!(cancelled))
setPost (nextPost)
}
catch (error)
{
console.error (error)
}
}) () }) ()
}, [theatreInfo.postId, theatreInfo.postStartedAt])
return () => {
cancelled = true
}
}, [theatreInfo.postId])
const syncPlayback = (meta: NiconicoMetadata) => { const syncPlayback = (meta: NiconicoMetadata) => {
if (!(theatreInfo.postStartedAt)) if (!(theatreInfo.postStartedAt))
return return
const targetTime = Math.min ( const targetTime =
Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ())
videoLength)
const drift = Math.abs (meta.currentTime - targetTime) const drift = Math.abs (meta.currentTime - targetTime)
@@ -228,11 +88,8 @@ export default (() => {
embedRef.current?.seek (targetTime) embedRef.current?.seek (targetTime)
} }
if (status >= 400)
return <ErrorScreen status={status}/>
return ( return (
<div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> <MainArea>
<Helmet> <Helmet>
{theatre && ( {theatre && (
<title> <title>
@@ -242,100 +99,16 @@ export default (() => {
</title>)} </title>)}
</Helmet> </Helmet>
<div className="hidden md:block"> {post && (
{post && <TagDetailSidebar post={post}/>} <PostEmbed
</div> ref={embedRef}
post={post}
<MainArea> onLoadComplete={info => {
{post ? ( embedRef.current?.play ()
<> setVideoLength (info.lengthInSeconds * 1_000)
<PostEmbed }}
key={post.id} onMetadataChange={meta => {
ref={embedRef} syncPlayback (meta)
post={post} }}/>)}
onLoadComplete={info => { </MainArea>)
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 }) satisfies FC
+10 -9
View File
@@ -7,6 +7,7 @@ import PostList from '@/components/PostList'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
import WikiBody from '@/components/WikiBody' import WikiBody from '@/components/WikiBody'
import PageTitle from '@/components/common/PageTitle'
import TabGroup, { Tab } from '@/components/common/TabGroup' import TabGroup, { Tab } from '@/components/common/TabGroup'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import { SITE_TITLE } from '@/config' import { SITE_TITLE } from '@/config'
@@ -106,15 +107,15 @@ export default () => {
</PrefetchLink>) : '(最新)'} </PrefetchLink>) : '(最新)'}
</div>)} </div>)}
<article className="prose dark:prose-invert mx-auto p-4"> <PageTitle>
<h1 className="prose-a:no-underline"> <TagLink tag={tag ?? defaultTag}
<TagLink tag={tag ?? defaultTag} withWiki={false}
withWiki={false} withCount={false}
withCount={false} {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/>
{...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> </PageTitle>
</h1> <div className="prose mx-auto p-4">
{loading ? <div>Loading...</div> : <WikiBody title={title} body={wikiPage?.body}/>} {loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>}
</article> </div>
{(!(version) && posts.length > 0) && ( {(!(version) && posts.length > 0) && (
<TabGroup> <TabGroup>
+8 -42
View File
@@ -49,34 +49,12 @@ export type FetchTagsParams = {
limit: number limit: number
order: FetchTagsOrder } order: FetchTagsOrder }
export type Material = {
id: number
tag: Tag
file: string | null
url: string | null
wikiPageBody?: string | null
contentType: string | null
createdAt: string
createdByUser: { id: number; name: string }
updatedAt: string
updatedByUser: { id: number; name: string } }
export type Menu = MenuItem[] export type Menu = MenuItem[]
export type MenuInvisibleItem = { export type MenuItem = {
name: ReactNode name: string
to?: string
base?: string
visible: false
subMenu: SubMenuItem[] }
export type MenuItem = MenuVisibleItem | MenuInvisibleItem
export type MenuVisibleItem = {
name: ReactNode
to: string to: string
base?: string base?: string
visible?: true
subMenu: SubMenuItem[] } subMenu: SubMenuItem[] }
export type NicoTag = Tag & { export type NicoTag = Tag & {
@@ -136,16 +114,12 @@ export type PostTagChange = {
changeType: 'add' | 'remove' changeType: 'add' | 'remove'
timestamp: string } timestamp: string }
export type SubMenuComponentItem = { export type SubMenuItem =
component: ReactNode | { component: ReactNode
visible: boolean } visible: boolean }
| { name: string
export type SubMenuItem = SubMenuComponentItem | SubMenuStringItem to: string
visible?: boolean }
export type SubMenuStringItem = {
name: ReactNode
to: string
visible?: boolean }
export type Tag = { export type Tag = {
id: number id: number
@@ -155,7 +129,6 @@ export type Tag = {
createdAt: string createdAt: string
updatedAt: string updatedAt: string
hasWiki: boolean hasWiki: boolean
materialId: number
children?: Tag[] children?: Tag[]
matchedAlias?: string | null } matchedAlias?: string | null }
@@ -168,13 +141,6 @@ export type Theatre = {
createdAt: string createdAt: string
updatedAt: string } updatedAt: string }
export type TheatreComment = {
theatreId: number,
no: number,
user: { id: number, name: string } | null
content: string
createdAt: string }
export type User = { export type User = {
id: number id: number
name: string | null name: string | null
+2 -2
View File
@@ -8,7 +8,7 @@ import { DARK_COLOUR_SHADE,
const colours = Object.values (TAG_COLOUR) const colours = Object.values (TAG_COLOUR)
export default { export default {
content: ['./src/**/*.{html,js,ts,jsx,tsx,mdx}'], content: ['./src/**/*.{html,js,ts,jsx,tsx}'],
safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`), safelist: [...colours.map (c => `text-${ c }-${ LIGHT_COLOUR_SHADE }`),
...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`), ...colours.map (c => `hover:text-${ c }-${ LIGHT_COLOUR_SHADE - 200 }`),
...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`), ...colours.map (c => `dark:text-${ c }-${ DARK_COLOUR_SHADE }`),
@@ -24,4 +24,4 @@ export default {
'rainbow-scroll': { 'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' }, '0%': { backgroundPosition: '0% 50%' },
'100%': { backgroundPosition: '200% 50%' } } } } }, '100%': { backgroundPosition: '200% 50%' } } } } },
plugins: [require ('@tailwindcss/typography')] } satisfies Config plugins: [] } satisfies Config
+2 -3
View File
@@ -1,12 +1,11 @@
import mdx from '@mdx-js/rollup' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path' import path from 'path'
import { defineConfig } from 'vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig ({ export default defineConfig ({
plugins: [mdx ({ providerImportSource: '@/mdx-components' }), react ()], plugins: [react()],
resolve: { alias: { '@': path.resolve (__dirname, './src') } }, resolve: { alias: { '@': path.resolve (__dirname, './src') } },
server: { host: true, server: { host: true,
port: 5173, port: 5173,