コミットを比較
13 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 4bf26c3ead | |||
| 5657fa7e7b | |||
| 77294e8caa | |||
| 0d6bf0d12b | |||
| 19a185d5b5 | |||
| 728ff11789 | |||
| 57905672f8 | |||
| 8ba008d683 | |||
| dd32c1e774 | |||
| acd062f289 | |||
| 877876b661 | |||
| bffd4c422a | |||
| 776dea87d9 |
@@ -140,10 +140,11 @@ class PostsController < ApplicationController
|
|||||||
original_created_from = params[:original_created_from]
|
original_created_from = params[:original_created_from]
|
||||||
original_created_before = params[:original_created_before]
|
original_created_before = params[:original_created_before]
|
||||||
parent_post_ids = parse_parent_post_ids
|
parent_post_ids = parse_parent_post_ids
|
||||||
|
resized_thumbnail = thumbnail.present? ? Post.resized_thumbnail_attachment(thumbnail) : nil
|
||||||
|
|
||||||
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) if thumbnail.present?
|
post.thumbnail.attach(resized_thumbnail) if resized_thumbnail
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
post.save!
|
post.save!
|
||||||
@@ -156,8 +157,6 @@ class PostsController < ApplicationController
|
|||||||
|
|
||||||
sync_parent_posts!(post, parent_post_ids)
|
sync_parent_posts!(post, parent_post_ids)
|
||||||
|
|
||||||
post.resized_thumbnail!
|
|
||||||
|
|
||||||
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -167,6 +166,8 @@ class PostsController < ApplicationController
|
|||||||
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
render_validation_error fields: { tags: 'ニコニコ・タグは直接指定できません.' }
|
||||||
rescue Tag::DeprecatedTagNormalisationError
|
rescue Tag::DeprecatedTagNormalisationError
|
||||||
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
render_unprocessable_entity '廃止済みタグは付与できません.', field: :tags
|
||||||
|
rescue MiniMagick::Error
|
||||||
|
render_validation_error fields: { thumbnail: ['サムネイル画像の変換に失敗しました.'] }
|
||||||
rescue ArgumentError => e
|
rescue ArgumentError => e
|
||||||
render_validation_error fields: { parent_post_ids: [e.message] }
|
render_validation_error fields: { parent_post_ids: [e.message] }
|
||||||
rescue ActiveRecord::RecordInvalid => e
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
|||||||
@@ -245,10 +245,7 @@ class TagsController < ApplicationController
|
|||||||
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
return render_unprocessable_entity('カテゴリは必須です.', field: :category) if category.blank?
|
||||||
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
return render_unprocessable_entity '廃止状態は必須です.', field: :deprecated unless params.key?(:deprecated)
|
||||||
|
|
||||||
if (name != tag.name &&
|
return unless validate_tag_rename(tag, name)
|
||||||
tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico]))
|
|
||||||
return render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
|
||||||
end
|
|
||||||
|
|
||||||
alias_names = params[:aliases].to_s.split.uniq
|
alias_names = params[:aliases].to_s.split.uniq
|
||||||
parent_names = params[:parent_tags].to_s.split.uniq
|
parent_names = params[:parent_tags].to_s.split.uniq
|
||||||
@@ -274,7 +271,7 @@ class TagsController < ApplicationController
|
|||||||
else
|
else
|
||||||
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
tag.update!(category:, deprecated_at: deprecated ? Time.current : nil)
|
||||||
end
|
end
|
||||||
tag.tag_name.update!(name:)
|
rename_tag_name!(tag, name) if name_changed
|
||||||
|
|
||||||
alias_names << old_name if name_changed
|
alias_names << old_name if name_changed
|
||||||
alias_names.delete(name)
|
alias_names.delete(name)
|
||||||
@@ -310,6 +307,8 @@ class TagsController < ApplicationController
|
|||||||
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
return render_unprocessable_entity 'ニコタグは廃止できません.', field: :deprecated
|
||||||
end
|
end
|
||||||
|
|
||||||
|
return unless validate_tag_rename(tag, name)
|
||||||
|
|
||||||
if tag.nico? || (category.present? && category == 'nico')
|
if tag.nico? || (category.present? && category == 'nico')
|
||||||
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
return render_unprocessable_entity 'ニコタグは変更できません.', field: :category
|
||||||
end
|
end
|
||||||
@@ -321,7 +320,7 @@ class TagsController < ApplicationController
|
|||||||
name_changed = name.present? && name != old_name
|
name_changed = name.present? && name != old_name
|
||||||
wiki_page = tag.tag_name.wiki_page if name_changed
|
wiki_page = tag.tag_name.wiki_page if name_changed
|
||||||
|
|
||||||
tag.tag_name.update!(name:) if name.present?
|
rename_tag_name!(tag, name) if name_changed
|
||||||
tag.update!(category:) if category.present?
|
tag.update!(category:) if category.present?
|
||||||
if deprecated_given && tag.deprecated? != deprecated
|
if deprecated_given && tag.deprecated? != deprecated
|
||||||
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
tag.update!(deprecated_at: deprecated ? Time.current : nil)
|
||||||
@@ -535,6 +534,67 @@ class TagsController < ApplicationController
|
|||||||
created_by_user:)
|
created_by_user:)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_tag_rename tag, name
|
||||||
|
return true if name.blank? || name == tag.name
|
||||||
|
|
||||||
|
if tag.in?([Tag.tagme, Tag.bot, Tag.no_deerjikist, Tag.video, Tag.niconico])
|
||||||
|
render_unprocessable_entity 'システム・タグの名称は変更できません.', field: :name
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
target_tag_name = TagName.with_discarded.find_by(name:)
|
||||||
|
return true if target_tag_name.nil?
|
||||||
|
return true if target_tag_name.canonical_id?
|
||||||
|
|
||||||
|
render_unprocessable_entity 'その名前は既に使はれてゐます.', field: :name
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def rename_tag_name! tag, name
|
||||||
|
return if name == tag.name
|
||||||
|
|
||||||
|
current_tag_name = tag.tag_name
|
||||||
|
target_tag_name = TagName.with_discarded.find_by(name:)
|
||||||
|
|
||||||
|
if target_tag_name.nil?
|
||||||
|
current_tag_name.update!(name:)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
promote_tag_alias!(
|
||||||
|
tag,
|
||||||
|
current_tag_name:,
|
||||||
|
promoted_tag_name: target_tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def promote_tag_alias! tag, current_tag_name:, promoted_tag_name:
|
||||||
|
old_owner_tag = promoted_tag_name.canonical&.tag
|
||||||
|
|
||||||
|
if old_owner_tag && old_owner_tag != tag
|
||||||
|
TagVersioning.ensure_snapshot!(old_owner_tag, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
promoted_tag_name.undiscard! if promoted_tag_name.discarded?
|
||||||
|
promoted_tag_name.update!(canonical: nil)
|
||||||
|
|
||||||
|
TagName.with_discarded
|
||||||
|
.where(canonical_id: current_tag_name.id)
|
||||||
|
.where.not(id: promoted_tag_name.id)
|
||||||
|
.find_each do |alias_tag_name|
|
||||||
|
alias_tag_name.update!(canonical: promoted_tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
current_tag_name.wiki_page&.update!(tag_name: promoted_tag_name)
|
||||||
|
tag.update!(tag_name: promoted_tag_name)
|
||||||
|
current_tag_name.association(:wiki_page).reset
|
||||||
|
current_tag_name.association(:tag).reset
|
||||||
|
current_tag_name.reload.update!(canonical: promoted_tag_name)
|
||||||
|
|
||||||
|
return unless old_owner_tag && old_owner_tag != tag
|
||||||
|
|
||||||
|
record_tag_version!(old_owner_tag.reload, event_type: :update, created_by_user: current_user)
|
||||||
|
end
|
||||||
|
|
||||||
def update_aliases! tag, alias_names
|
def update_aliases! tag, alias_names
|
||||||
alias_names = alias_names.uniq
|
alias_names = alias_names.uniq
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
class Post < ApplicationRecord
|
class Post < ApplicationRecord
|
||||||
require 'mini_magick'
|
require 'mini_magick'
|
||||||
|
require 'stringio'
|
||||||
|
|
||||||
|
def self.resized_thumbnail_attachment(upload)
|
||||||
|
upload.rewind
|
||||||
|
image = MiniMagick::Image.read(upload.read)
|
||||||
|
image.resize '180x180'
|
||||||
|
image.format 'jpg'
|
||||||
|
|
||||||
|
{ io: StringIO.new(image.to_blob),
|
||||||
|
filename: 'resized_thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg' }
|
||||||
|
ensure
|
||||||
|
upload.rewind
|
||||||
|
end
|
||||||
|
|
||||||
belongs_to :uploaded_user, class_name: 'User', optional: true
|
belongs_to :uploaded_user, class_name: 'User', optional: true
|
||||||
|
|
||||||
@@ -87,12 +101,7 @@ class Post < ApplicationRecord
|
|||||||
def resized_thumbnail!
|
def resized_thumbnail!
|
||||||
return unless thumbnail.attached?
|
return unless thumbnail.attached?
|
||||||
|
|
||||||
image = MiniMagick::Image.read(thumbnail.download)
|
thumbnail.attach(self.class.resized_thumbnail_attachment(StringIO.new(thumbnail.download)))
|
||||||
image.resize '180x180'
|
|
||||||
thumbnail.purge
|
|
||||||
thumbnail.attach(io: File.open(image.path),
|
|
||||||
filename: 'resized_thumbnail.jpg',
|
|
||||||
content_type: 'image/jpeg')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class TagImplication < ApplicationRecord
|
|||||||
validates :parent_tag_id, presence: true
|
validates :parent_tag_id, presence: true
|
||||||
|
|
||||||
validate :parent_tag_mustnt_be_itself
|
validate :parent_tag_mustnt_be_itself
|
||||||
|
validate :parent_tag_mustnt_create_cycle
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -14,4 +15,27 @@ class TagImplication < ApplicationRecord
|
|||||||
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
errors.add :parent_tag_id, '親タグは子タグと同一であってはなりません.'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parent_tag_mustnt_create_cycle
|
||||||
|
return if tag_id.blank? || parent_tag_id.blank?
|
||||||
|
return if errors[:parent_tag_id].present?
|
||||||
|
|
||||||
|
seen = { }
|
||||||
|
stack = [parent_tag_id]
|
||||||
|
|
||||||
|
until stack.empty?
|
||||||
|
current_id = stack.pop
|
||||||
|
next if seen[current_id]
|
||||||
|
|
||||||
|
seen[current_id] = true
|
||||||
|
|
||||||
|
if current_id == tag_id
|
||||||
|
errors.add :parent_tag_id, '親タグに子孫タグを指定すると循環します.'
|
||||||
|
errors.add :base, 'タグの親子関係が循環します.'
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
stack.concat(TagImplication.where(tag_id: current_id).pluck(:parent_tag_id))
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TagImplication, type: :model do
|
||||||
|
it 'rejects a parent tag that would create a cycle' do
|
||||||
|
child = create(:tag, name: 'tag_implication_cycle_child')
|
||||||
|
parent = create(:tag, name: 'tag_implication_cycle_parent')
|
||||||
|
|
||||||
|
described_class.create!(tag: child, parent_tag: parent)
|
||||||
|
|
||||||
|
implication = described_class.new(tag: parent, parent_tag: child)
|
||||||
|
|
||||||
|
expect(implication).not_to be_valid
|
||||||
|
expect(implication.errors[:parent_tag_id]).to include(
|
||||||
|
'親タグに子孫タグを指定すると循環します.'
|
||||||
|
)
|
||||||
|
expect(implication.errors[:base]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'terminates even when existing data already contains a cycle' do
|
||||||
|
child = create(:tag, name: 'tag_implication_existing_cycle_child')
|
||||||
|
parent = create(:tag, name: 'tag_implication_existing_cycle_parent')
|
||||||
|
ancestor = create(:tag, name: 'tag_implication_existing_cycle_ancestor')
|
||||||
|
|
||||||
|
described_class.create!(tag: parent, parent_tag: ancestor)
|
||||||
|
described_class.insert_all!(
|
||||||
|
[
|
||||||
|
{ tag_id: ancestor.id, parent_tag_id: parent.id,
|
||||||
|
created_at: Time.current, updated_at: Time.current }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
implication = described_class.new(tag: child, parent_tag: parent)
|
||||||
|
|
||||||
|
expect(implication).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -54,7 +54,17 @@ RSpec.describe Tag, type: :model do
|
|||||||
first = create(:tag, name: 'expand_cycle_first')
|
first = create(:tag, name: 'expand_cycle_first')
|
||||||
second = create(:tag, name: 'expand_cycle_second')
|
second = create(:tag, name: 'expand_cycle_second')
|
||||||
TagImplication.create!(tag: first, parent_tag: second)
|
TagImplication.create!(tag: first, parent_tag: second)
|
||||||
TagImplication.create!(tag: second, parent_tag: first)
|
now = Time.current
|
||||||
|
TagImplication.insert_all!(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag_id: second.id,
|
||||||
|
parent_tag_id: first.id,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
expect(described_class.expand_parent_tags([first])).to contain_exactly(first, second)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'base64'
|
||||||
require 'set'
|
require 'set'
|
||||||
|
|
||||||
include ActiveSupport::Testing::TimeHelpers
|
include ActiveSupport::Testing::TimeHelpers
|
||||||
@@ -8,6 +9,11 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
# resized_thumbnail! が MiniMagick 依存でコケやすいので request spec ではスタブしとくのが無難。
|
||||||
before do
|
before do
|
||||||
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
allow_any_instance_of(Post).to receive(:resized_thumbnail!).and_return(true)
|
||||||
|
allow(Post).to receive(:resized_thumbnail_attachment).and_return(
|
||||||
|
io: StringIO.new('dummy'),
|
||||||
|
filename: 'resized_thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg'
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_nico_tag!(name)
|
def create_nico_tag!(name)
|
||||||
@@ -19,6 +25,18 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def real_thumbnail_upload
|
||||||
|
gif =
|
||||||
|
Base64.decode64(
|
||||||
|
'R0lGODdhAQABAIABAP///wAAACwAAAAAAQABAAACAkQBADs=')
|
||||||
|
|
||||||
|
Rack::Test::UploadedFile.new(
|
||||||
|
StringIO.new(gif),
|
||||||
|
'image/gif',
|
||||||
|
original_filename: 'thumbnail.gif'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
def post_write_params params = { }
|
def post_write_params params = { }
|
||||||
{ parent_post_ids: '' }.merge(params)
|
{ parent_post_ids: '' }.merge(params)
|
||||||
end
|
end
|
||||||
@@ -696,6 +714,66 @@ RSpec.describe 'Posts API', type: :request do
|
|||||||
expect(json['tags'][0]).to have_key('name')
|
expect(json['tags'][0]).to have_key('name')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it '201 when posting manually with a thumbnail' do
|
||||||
|
sign_in_as(member)
|
||||||
|
allow(Post).to receive(:resized_thumbnail_attachment).and_call_original
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'thumbnail post',
|
||||||
|
url: 'https://example.com/thumbnail-post',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: real_thumbnail_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
post_record = Post.find(json.fetch('id'))
|
||||||
|
expect(post_record.thumbnail).to be_attached
|
||||||
|
expect(post_record.thumbnail.blob.content_type).to eq('image/jpeg')
|
||||||
|
expect { post_record.thumbnail.download }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resizes the thumbnail before the create transaction begins' do
|
||||||
|
sign_in_as(member)
|
||||||
|
|
||||||
|
open_transactions = []
|
||||||
|
baseline_open_transactions = Post.connection.open_transactions
|
||||||
|
allow(Post).to receive(:resized_thumbnail_attachment) do |_upload|
|
||||||
|
open_transactions << Post.connection.open_transactions
|
||||||
|
{ io: StringIO.new('dummy'),
|
||||||
|
filename: 'resized_thumbnail.jpg',
|
||||||
|
content_type: 'image/jpeg' }
|
||||||
|
end
|
||||||
|
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'transaction post',
|
||||||
|
url: 'https://example.com/transaction-post',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:created)
|
||||||
|
expect(open_transactions).to eq([baseline_open_transactions])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 422 and does not create a post when thumbnail resize fails' do
|
||||||
|
sign_in_as(member)
|
||||||
|
allow(Post).to receive(:resized_thumbnail_attachment).and_raise(MiniMagick::Error)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/posts', params: post_write_params(
|
||||||
|
title: 'broken thumbnail post',
|
||||||
|
url: 'https://example.com/broken-thumbnail-post',
|
||||||
|
tags: 'spec_tag',
|
||||||
|
thumbnail: dummy_upload
|
||||||
|
)
|
||||||
|
}.not_to change(Post, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'thumbnail' => ['サムネイル画像の変換に失敗しました.']
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
it '201 and creates post + tags when member and tags have aliases' do
|
it '201 and creates post + tags when member and tags have aliases' do
|
||||||
sign_in_as(member)
|
sign_in_as(member)
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,17 @@ RSpec.describe "TagChildren", type: :request do
|
|||||||
|
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns 422 and does not create relation when the new link makes a cycle' do
|
||||||
|
TagImplication.create!(tag: parent, parent_tag: child)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
do_request
|
||||||
|
}.not_to change(TagImplication, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(TagImplication.where(tag: child, parent_tag: parent)).not_to exist
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context "when Tag.find raises (invalid ids)" do
|
context "when Tag.find raises (invalid ids)" do
|
||||||
|
|||||||
@@ -581,6 +581,58 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
|
expect(wiki_page.reload.wiki_versions.count).to eq(before_wiki_version_count)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'full update で旧名 alias が残った tag を PATCH で旧名へ戻せる' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'patch_roundtrip_target',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.reload.tag_name)
|
||||||
|
|
||||||
|
patch "/tags/#{ tag.id }", params: { name: 'spec_tag' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('spec_tag')
|
||||||
|
expect(tag.tag_name.canonical_id).to be_nil
|
||||||
|
expect(TagName.find_by!(name: 'patch_roundtrip_target').canonical).to eq(tag.tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it '別 tag の正規名には変更できない' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'patch collision wiki',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
patch "/tags/#{ tag.id }", params: { name: 'unknown' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json.fetch('errors')).to include(
|
||||||
|
'name' => ['その名前は既に使はれてゐます.']
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(tag.reload.name).to eq('spec_tag')
|
||||||
|
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('unko')
|
||||||
|
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'system tag の name は変更できない' do
|
||||||
|
system_tag = Tag.bot
|
||||||
|
|
||||||
|
patch "/tags/#{ system_tag.id }", params: { name: 'patch_system_tag_renamed' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(system_tag.reload.name).to eq('bot操作')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -745,7 +797,17 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
)
|
)
|
||||||
TagImplication.create!(tag: first, parent_tag: root_material)
|
TagImplication.create!(tag: first, parent_tag: root_material)
|
||||||
TagImplication.create!(tag: second, parent_tag: first)
|
TagImplication.create!(tag: second, parent_tag: first)
|
||||||
TagImplication.create!(tag: first, parent_tag: second)
|
now = Time.current
|
||||||
|
TagImplication.insert_all!(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
tag_id: first.id,
|
||||||
|
parent_tag_id: second.id,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
get '/tags/with-depth', params: { parent: root_material.id }
|
get '/tags/with-depth', params: { parent: root_material.id }
|
||||||
|
|
||||||
@@ -1029,6 +1091,42 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
expect(versions.second.created_by_user_id).to eq(member_user.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it '同じ tag の旧 alias へ戻しても rename できる' do
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_roundtrip_b',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('spec_tag')
|
||||||
|
expect(TagName.find_by!(name: 'put_roundtrip_b').canonical).to eq(tag.tag_name)
|
||||||
|
expect(tag.tag_name.aliases.map(&:name)).to contain_exactly('put_roundtrip_b', 'unko')
|
||||||
|
expect(tag.tag_name.aliases.map(&:name)).not_to include('spec_tag')
|
||||||
|
expect(alias_tn.reload.canonical).to eq(tag.tag_name)
|
||||||
|
|
||||||
|
version = tag.tag_versions.order(:version_no).last
|
||||||
|
|
||||||
|
expect(version.event_type).to eq('update')
|
||||||
|
expect(version.name).to eq('spec_tag')
|
||||||
|
expect(version.aliases.split).to contain_exactly('put_roundtrip_b', 'unko')
|
||||||
|
end
|
||||||
|
|
||||||
it 'parent tag の snapshot も作成する' do
|
it 'parent tag の snapshot も作成する' do
|
||||||
old_parent = Tag.create!(
|
old_parent = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
|
tag_name: TagName.create!(name: 'put_snapshot_old_parent'),
|
||||||
@@ -1153,6 +1251,48 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'wiki を持つ tag を旧 alias へ戻しても wiki を新 canonical へ移す' do
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'wiki body before',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_wiki_roundtrip_b',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'spec_tag',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.to change(TagVersion, :count).by(3)
|
||||||
|
.and change(WikiVersion, :count).by(2)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
|
||||||
|
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||||
|
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').wiki_page).to be_nil
|
||||||
|
expect(TagName.find_by!(name: 'put_wiki_roundtrip_b').canonical).to eq(tag.tag_name)
|
||||||
|
|
||||||
|
versions = wiki_page.wiki_versions.order(:version_no).last(2)
|
||||||
|
|
||||||
|
expect(versions.map(&:event_type)).to eq(['update', 'update'])
|
||||||
|
expect(versions.map(&:title)).to eq(['put_wiki_roundtrip_b', 'spec_tag'])
|
||||||
|
end
|
||||||
|
|
||||||
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
it '別名を他 tag から奪った場合、奪はれた側の tag version も作成する' do
|
||||||
old_owner = Tag.create!(
|
old_owner = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_alias_old_owner'),
|
tag_name: TagName.create!(name: 'put_alias_old_owner'),
|
||||||
@@ -1191,9 +1331,49 @@ RSpec.describe 'Tags API', type: :request do
|
|||||||
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
|
expect(old_owner_versions.second.aliases.split).not_to include('put_stolen_alias')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
it '別 tag の alias 名を rename で奪へる' do
|
||||||
pending '#332 で対応予定'
|
old_owner = Tag.create!(
|
||||||
|
tag_name: TagName.create!(name: 'put_alias_collision_owner'),
|
||||||
|
category: :general
|
||||||
|
)
|
||||||
|
stolen_alias = TagName.create!(
|
||||||
|
name: 'put_alias_collision_name',
|
||||||
|
canonical: old_owner.tag_name
|
||||||
|
)
|
||||||
|
wiki_page =
|
||||||
|
Wiki::Commit.create_content!(
|
||||||
|
tag_name: tag.tag_name,
|
||||||
|
body: 'put collision wiki',
|
||||||
|
created_by_user: member_user,
|
||||||
|
message: 'init')
|
||||||
|
|
||||||
|
put "/tags/#{ tag.id }", params: {
|
||||||
|
name: 'put_alias_collision_name',
|
||||||
|
category: 'general',
|
||||||
|
aliases: 'unko',
|
||||||
|
parent_tags: '',
|
||||||
|
deprecated: '0',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
tag.reload
|
||||||
|
old_owner.reload
|
||||||
|
stolen_alias.reload
|
||||||
|
|
||||||
|
expect(tag.name).to eq('put_alias_collision_name')
|
||||||
|
expect(stolen_alias.canonical_id).to be_nil
|
||||||
|
expect(TagName.find_by!(name: 'spec_tag').canonical).to eq(tag.tag_name)
|
||||||
|
expect(old_owner.tag_name.aliases.map(&:name)).not_to include('put_alias_collision_name')
|
||||||
|
|
||||||
|
old_owner_versions = old_owner.tag_versions.order(:version_no)
|
||||||
|
|
||||||
|
expect(old_owner_versions.last.event_type).to eq('update')
|
||||||
|
expect(old_owner_versions.last.aliases.split).not_to include('put_alias_collision_name')
|
||||||
|
expect(wiki_page.reload.tag_name).to eq(tag.tag_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'parent_tags に指定すると循環する tag は 422 にする' do
|
||||||
child = Tag.create!(
|
child = Tag.create!(
|
||||||
tag_name: TagName.create!(name: 'put_cycle_child'),
|
tag_name: TagName.create!(name: 'put_cycle_child'),
|
||||||
category: :general
|
category: :general
|
||||||
|
|||||||
+3
-1
@@ -6,6 +6,7 @@ import { BrowserRouter,
|
|||||||
Routes,
|
Routes,
|
||||||
useLocation } from 'react-router-dom'
|
useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
@@ -145,6 +146,7 @@ const App: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RouteBlockerOverlay/>
|
<RouteBlockerOverlay/>
|
||||||
|
{import.meta.env.DEV && <DevModeWatermark/>}
|
||||||
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
@@ -152,7 +154,7 @@ const App: FC = () => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout="position"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
className="flex flex-col h-dvh w-full overflow-y-hidden">
|
className="relative flex flex-col h-dvh w-full overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user}/>
|
||||||
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
<RouteTransitionWrapper user={user} setUser={setUser}/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
const ROW_COUNT = 8
|
||||||
|
const COLUMN_COUNT = 12
|
||||||
|
|
||||||
|
|
||||||
|
const DevModeWatermark: FC = () => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none select-none fixed inset-0 overflow-hidden z-0">
|
||||||
|
<style>{`
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Nikumaru';
|
||||||
|
src: url(${nikumaru}) format('opentype');
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<div className="absolute -inset-32 flex flex-col justify-center gap-12 py-48">
|
||||||
|
{Array.from ({ length: ROW_COUNT }, (_, rowIndex) => (
|
||||||
|
<div
|
||||||
|
key={rowIndex}
|
||||||
|
className={
|
||||||
|
'flex min-h-32 items-center gap-12 '
|
||||||
|
+ (rowIndex % 2 === 0 ? 'translate-x-0' : 'translate-x-32')
|
||||||
|
}>
|
||||||
|
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||||
|
<span
|
||||||
|
key={columnIndex}
|
||||||
|
className={
|
||||||
|
'whitespace-nowrap text-3xl font-bold '
|
||||||
|
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||||
|
+ 'dark:text-white/10'
|
||||||
|
}
|
||||||
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
|
開発モード
|
||||||
|
</span>))}
|
||||||
|
</div>))}
|
||||||
|
</div>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default DevModeWatermark
|
||||||
@@ -87,10 +87,13 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
setDropRef (node)
|
setDropRef (node)
|
||||||
}}
|
}}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn ('rounded select-none', over && 'ring-2 ring-offset-2')}
|
className={cn (
|
||||||
|
'min-w-0 max-w-full overflow-hidden rounded select-none',
|
||||||
|
over && 'ring-2 ring-offset-2')}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}>
|
{...listeners}>
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
layoutId={`tag-${ sp ? 'sp-' : '' }${ tag.id }`}>
|
||||||
<TagLink tag={tag} nestLevel={nestLevel}/>
|
<TagLink tag={tag} nestLevel={nestLevel}/>
|
||||||
@@ -98,4 +101,4 @@ const DraggableDroppableTagRow: FC<Props> = ({ tag, nestLevel, pathKey, parentTa
|
|||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DraggableDroppableTagRow
|
export default DraggableDroppableTagRow
|
||||||
|
|||||||
@@ -318,12 +318,20 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
|||||||
<TagLink
|
<TagLink
|
||||||
tag={sidebarTagToTag (tag)}
|
tag={sidebarTagToTag (tag)}
|
||||||
title={tag.name}
|
title={tag.name}
|
||||||
|
truncateOnMobile
|
||||||
withCount={false}
|
withCount={false}
|
||||||
withWiki={false}
|
withWiki={false}
|
||||||
to={materialPath (tag.id, materialFilter)}
|
to={materialPath (tag.id, materialFilter)}
|
||||||
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
className="block overflow-hidden text-ellipsis whitespace-nowrap
|
||||||
[max-inline-size:var(--tag-link-inline-size)]
|
[max-inline-size:var(--tag-link-inline-size)]
|
||||||
[max-height:var(--tag-link-inline-size)]"/>
|
[max-height:var(--tag-link-inline-size)]
|
||||||
|
[&_.tag-marquee]:block
|
||||||
|
[&_.tag-marquee]:h-full
|
||||||
|
[&_.tag-marquee]:overflow-hidden
|
||||||
|
[&_.tag-marquee__static]:h-full
|
||||||
|
[&_.tag-marquee__static]:overflow-hidden
|
||||||
|
[&_.tag-marquee__static]:text-ellipsis
|
||||||
|
[&_.tag-marquee__static]:[text-overflow:ellipsis]"/>
|
||||||
</div>
|
</div>
|
||||||
{tag.hasChildren && (
|
{tag.hasChildren && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
text: string
|
||||||
|
className?: string
|
||||||
|
truncateOnMobile?: boolean
|
||||||
|
title?: string }
|
||||||
|
|
||||||
|
const DESKTOP_MARQUEE_MEDIA =
|
||||||
|
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
|
||||||
|
const MARQUEE_START_DELAY_MS = 1600
|
||||||
|
const MARQUEE_END_HOLD_MS = 2400
|
||||||
|
const MARQUEE_SCROLL_PX_PER_SECOND = 43
|
||||||
|
const MIN_MARQUEE_OVERFLOW_PX = 1
|
||||||
|
|
||||||
|
|
||||||
|
const ResponsiveMarqueeText: FC<Props> = (
|
||||||
|
{ text, className, truncateOnMobile = false, title },
|
||||||
|
) => {
|
||||||
|
const outerRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const staticRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const animatedRef = useRef<HTMLSpanElement | null> (null)
|
||||||
|
const animationRef = useRef<Animation | null> (null)
|
||||||
|
const timeoutRef = useRef<number | null> (null)
|
||||||
|
const rafRef = useRef<number | null> (null)
|
||||||
|
const [overflowPx, setOverflowPx] = useState (0)
|
||||||
|
const [desktopMarqueeEnabled, setDesktopMarqueeEnabled] = useState (false)
|
||||||
|
const [active, setActive] = useState (false)
|
||||||
|
const [marqueeVisible, setMarqueeVisible] = useState (false)
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const outer = outerRef.current
|
||||||
|
const inner = staticRef.current
|
||||||
|
|
||||||
|
if (!(outer) || !(inner))
|
||||||
|
return
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const nextOverflow = Math.max (0, Math.ceil (inner.scrollWidth - outer.clientWidth))
|
||||||
|
setOverflowPx (prev => Math.abs (prev - nextOverflow) <= 1 ? prev : nextOverflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
measure ()
|
||||||
|
|
||||||
|
const resizeObserver =
|
||||||
|
typeof ResizeObserver === 'undefined'
|
||||||
|
? null
|
||||||
|
: new ResizeObserver (() => {
|
||||||
|
measure ()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver?.observe (outer)
|
||||||
|
resizeObserver?.observe (inner)
|
||||||
|
addEventListener ('resize', measure)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver?.disconnect ()
|
||||||
|
removeEventListener ('resize', measure)
|
||||||
|
}
|
||||||
|
}, [text])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
||||||
|
return
|
||||||
|
|
||||||
|
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
|
||||||
|
const update = () => {
|
||||||
|
setDesktopMarqueeEnabled (media.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
update ()
|
||||||
|
media.addEventListener ('change', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener ('change', update)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
const clearScheduled = () => {
|
||||||
|
if (timeoutRef.current != null)
|
||||||
|
{
|
||||||
|
clearTimeout (timeoutRef.current)
|
||||||
|
timeoutRef.current = null
|
||||||
|
}
|
||||||
|
if (rafRef.current != null)
|
||||||
|
{
|
||||||
|
cancelAnimationFrame (rafRef.current)
|
||||||
|
rafRef.current = null
|
||||||
|
}
|
||||||
|
animationRef.current?.cancel ()
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const animated = animatedRef.current
|
||||||
|
const canMarquee = (
|
||||||
|
active
|
||||||
|
&& desktopMarqueeEnabled
|
||||||
|
&& overflowPx >= MIN_MARQUEE_OVERFLOW_PX
|
||||||
|
&& animated != null)
|
||||||
|
|
||||||
|
const resetAnimated = () => {
|
||||||
|
const node = animatedRef.current
|
||||||
|
|
||||||
|
animationRef.current?.cancel ()
|
||||||
|
animationRef.current = null
|
||||||
|
node?.getAnimations?.().forEach (animation => {
|
||||||
|
animation.cancel ()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!(node))
|
||||||
|
return
|
||||||
|
|
||||||
|
node.style.transition = 'none'
|
||||||
|
node.style.transform = 'translateX(0)'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canMarquee)
|
||||||
|
{
|
||||||
|
clearScheduled ()
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const sleep = (ms: number) =>
|
||||||
|
new Promise<void> (resolve => {
|
||||||
|
timeoutRef.current = window.setTimeout (() => {
|
||||||
|
timeoutRef.current = null
|
||||||
|
resolve ()
|
||||||
|
}, ms)
|
||||||
|
})
|
||||||
|
|
||||||
|
const runLoop = async () => {
|
||||||
|
while (!cancelled)
|
||||||
|
{
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (true)
|
||||||
|
|
||||||
|
await sleep (MARQUEE_START_DELAY_MS)
|
||||||
|
if (cancelled || !(animatedRef.current))
|
||||||
|
break
|
||||||
|
|
||||||
|
const moveDurationMs =
|
||||||
|
overflowPx / MARQUEE_SCROLL_PX_PER_SECOND * 1000
|
||||||
|
const node = animatedRef.current
|
||||||
|
|
||||||
|
if (typeof node.animate === 'function')
|
||||||
|
{
|
||||||
|
const animation = node.animate (
|
||||||
|
[
|
||||||
|
{ transform: 'translateX(0)' },
|
||||||
|
{ transform: `translateX(-${ overflowPx }px)` },
|
||||||
|
],
|
||||||
|
{ duration: moveDurationMs, easing: 'linear', fill: 'forwards' })
|
||||||
|
animationRef.current = animation
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await animation.finished
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
animation.cancel ()
|
||||||
|
if (animationRef.current === animation)
|
||||||
|
animationRef.current = null
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
node.style.transition = `transform ${ moveDurationMs }ms linear`
|
||||||
|
await new Promise<void> (resolve => {
|
||||||
|
rafRef.current = requestAnimationFrame (() => {
|
||||||
|
rafRef.current = null
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
resolve ()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await sleep (moveDurationMs)
|
||||||
|
node.style.transition = 'none'
|
||||||
|
node.style.transform = `translateX(-${ overflowPx }px)`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cancelled)
|
||||||
|
break
|
||||||
|
|
||||||
|
await sleep (MARQUEE_END_HOLD_MS)
|
||||||
|
if (cancelled)
|
||||||
|
break
|
||||||
|
|
||||||
|
resetAnimated ()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void runLoop ()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearScheduled ()
|
||||||
|
resetAnimated ()
|
||||||
|
setMarqueeVisible (false)
|
||||||
|
}
|
||||||
|
}, [active, desktopMarqueeEnabled, overflowPx, text])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
ref={outerRef}
|
||||||
|
title={title ?? text}
|
||||||
|
onMouseEnter={() => setActive (true)}
|
||||||
|
onMouseLeave={() => setActive (false)}
|
||||||
|
onFocus={() => setActive (true)}
|
||||||
|
onBlur={() => setActive (false)}
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee inline-block max-w-full min-w-0 align-bottom',
|
||||||
|
(truncateOnMobile
|
||||||
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||||
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||||
|
'md:overflow-hidden md:whitespace-nowrap',
|
||||||
|
className)}>
|
||||||
|
<span
|
||||||
|
ref={staticRef}
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee__static block max-w-full',
|
||||||
|
(truncateOnMobile
|
||||||
|
? 'overflow-hidden text-ellipsis whitespace-nowrap'
|
||||||
|
: 'whitespace-normal [overflow-wrap:anywhere]'),
|
||||||
|
'md:overflow-hidden md:text-ellipsis md:whitespace-nowrap',
|
||||||
|
marqueeVisible && 'md:opacity-0')}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
{overflowPx >= MIN_MARQUEE_OVERFLOW_PX && (
|
||||||
|
<span
|
||||||
|
ref={animatedRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn (
|
||||||
|
'tag-marquee__animated hidden md:block',
|
||||||
|
marqueeVisible ? 'md:opacity-100' : 'md:opacity-0')}>
|
||||||
|
{text}
|
||||||
|
</span>)}
|
||||||
|
</span>)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResponsiveMarqueeText
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||||
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import type { Tag } from '@/types'
|
|||||||
type CommonProps = {
|
type CommonProps = {
|
||||||
tag: Tag
|
tag: Tag
|
||||||
nestLevel?: number
|
nestLevel?: number
|
||||||
|
truncateOnMobile?: boolean
|
||||||
withWiki?: boolean
|
withWiki?: boolean
|
||||||
withCount?: boolean }
|
withCount?: boolean }
|
||||||
|
|
||||||
@@ -30,9 +32,11 @@ type Props =
|
|||||||
const TagLink: FC<Props> = ({ tag,
|
const TagLink: FC<Props> = ({ tag,
|
||||||
nestLevel = 0,
|
nestLevel = 0,
|
||||||
linkFlg = true,
|
linkFlg = true,
|
||||||
|
truncateOnMobile = false,
|
||||||
withWiki = true,
|
withWiki = true,
|
||||||
withCount = true,
|
withCount = true,
|
||||||
className,
|
className,
|
||||||
|
title,
|
||||||
...props }) => {
|
...props }) => {
|
||||||
const spanClass = cn (
|
const spanClass = cn (
|
||||||
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
`text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE }`,
|
||||||
@@ -41,11 +45,18 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
spanClass,
|
spanClass,
|
||||||
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
|
`hover:text-${ TAG_COLOUR[tag.category] }-${ LIGHT_COLOUR_SHADE - 200 }`,
|
||||||
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
|
`dark:hover:text-${ TAG_COLOUR[tag.category] }-${ DARK_COLOUR_SHADE - 200 }`)
|
||||||
|
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
|
||||||
|
const rootClass =
|
||||||
|
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline gap-x-1 md:items-baseline'
|
||||||
|
const markerWrapClass = 'shrink-0 self-start md:self-auto'
|
||||||
|
const countClass = 'shrink-0 self-end md:self-auto'
|
||||||
|
const textTitle = title
|
||||||
|
?? (tag.matchedAlias == null ? tag.name : `${ tag.matchedAlias } → ${ tag.name }`)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<span className={rootClass}>
|
||||||
{(linkFlg && withWiki) && (
|
{(linkFlg && withWiki) && (
|
||||||
<span className="mr-1">
|
<span className={markerWrapClass}>
|
||||||
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
{(tag.materialId != null || tag.hasWiki || tag.hasDeerjikists)
|
||||||
? (
|
? (
|
||||||
tag.materialId == null && !(tag.hasDeerjikists)
|
tag.materialId == null && !(tag.hasDeerjikists)
|
||||||
@@ -100,33 +111,48 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
</span>)}
|
</span>)}
|
||||||
{nestLevel > 0 && (
|
{nestLevel > 0 && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 mr-1"
|
className="ml-1 mr-1 shrink-0"
|
||||||
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
|
||||||
↳
|
↳
|
||||||
</span>)}
|
</span>)}
|
||||||
{tag.matchedAlias != null && (
|
{tag.matchedAlias != null && (
|
||||||
<>
|
<>
|
||||||
<span className={cn (spanClass, className)} {...props}>
|
<span
|
||||||
{tag.matchedAlias}
|
title={textTitle}
|
||||||
|
className={cn (spanClass, textClass, className)}
|
||||||
|
{...props}>
|
||||||
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.matchedAlias}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>
|
</span>
|
||||||
<> → </>
|
<span className="shrink-0"> → </span>
|
||||||
</>)}
|
</>)}
|
||||||
{linkFlg
|
{linkFlg
|
||||||
? (
|
? (
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`}
|
||||||
className={cn (linkClass, className)}
|
title={textTitle}
|
||||||
|
className={cn (linkClass, textClass, className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{tag.name}
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.name}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
<span className={cn (spanClass, className)}
|
<span
|
||||||
|
title={textTitle}
|
||||||
|
className={cn (spanClass, textClass, className)}
|
||||||
{...props}>
|
{...props}>
|
||||||
{tag.name}
|
<ResponsiveMarqueeText
|
||||||
|
text={tag.name}
|
||||||
|
title={textTitle}
|
||||||
|
truncateOnMobile={truncateOnMobile}/>
|
||||||
</span>)}
|
</span>)}
|
||||||
{withCount && (
|
{withCount && (
|
||||||
<span className="ml-1">{tag.postCount}</span>)}
|
<span className={countClass}>{tag.postCount}</span>)}
|
||||||
</>)
|
</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagLink
|
export default TagLink
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
|
|||||||
rounded shadow">
|
rounded shadow">
|
||||||
{suggestions.map ((tag, i) => (
|
{suggestions.map ((tag, i) => (
|
||||||
<li key={tag.id}
|
<li key={tag.id}
|
||||||
className={cn ('px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
className={cn ('min-w-0 overflow-hidden px-3 py-2 cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-700',
|
||||||
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
i === activeIndex && 'bg-gray-300 dark:bg-gray-700')}
|
||||||
onMouseDown={() => onSelect (tag)}>
|
onMouseDown={() => onSelect (tag)}>
|
||||||
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
<div className="flex min-w-0 max-w-full items-baseline overflow-hidden">
|
||||||
|
<TagLink tag={tag} linkFlg={false} withWiki={false}/>
|
||||||
|
</div>
|
||||||
</li>))}
|
</li>))}
|
||||||
</ul>)
|
</ul>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagSearchBox
|
export default TagSearchBox
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
<ul>
|
<ul>
|
||||||
{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 min-w-0 max-w-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
className="flex min-w-0 max-w-full items-baseline overflow-hidden"
|
||||||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||||||
layoutId={`tag-${ tag.id }`}>
|
layoutId={`tag-${ tag.id }`}>
|
||||||
<TagLink tag={tag} onClick={onClick}/>
|
<TagLink tag={tag} onClick={onClick}/>
|
||||||
@@ -128,4 +129,4 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagSidebar
|
export default TagSidebar
|
||||||
|
|||||||
@@ -132,3 +132,20 @@ body
|
|||||||
0%, 100% { color: #f87171; }
|
0%, 100% { color: #f87171; }
|
||||||
50% { color: #60a5fa; }
|
50% { color: #60a5fa; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-marquee
|
||||||
|
{
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-marquee__animated
|
||||||
|
{
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
min-width: 100%;
|
||||||
|
width: max-content;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||||
import { Route, Routes } from 'react-router-dom'
|
import { Route, Routes } from 'react-router-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage'
|
||||||
import { buildPost,
|
import { buildPost,
|
||||||
|
buildTag,
|
||||||
buildTheatre,
|
buildTheatre,
|
||||||
buildTheatreComment,
|
buildTheatreComment,
|
||||||
buildTheatreInfo,
|
buildTheatreInfo,
|
||||||
@@ -96,6 +97,9 @@ const renderPage = (user = buildUser ({ id: 1, role: 'member' })) =>
|
|||||||
{ route: '/theatres/7' },
|
{ route: '/theatres/7' },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const tagSection = (): HTMLElement =>
|
||||||
|
screen.getAllByRole ('heading', { name: 'タグ' })[0].closest ('section')!
|
||||||
|
|
||||||
const mockDefaultApi = () => {
|
const mockDefaultApi = () => {
|
||||||
api.apiGet.mockImplementation ((path: string) => {
|
api.apiGet.mockImplementation ((path: string) => {
|
||||||
switch (path)
|
switch (path)
|
||||||
@@ -245,6 +249,53 @@ describe ('TheatreDetailPage', () => {
|
|||||||
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
expect (postEmbed.seek).not.toHaveBeenCalledWith (0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it ('shows child tags from the post tag tree in both vertical and horizontal layouts', async () => {
|
||||||
|
const childTag = buildTag ({ id: 12, name: '子タグ', category: 'general' })
|
||||||
|
const parentTag = buildTag ({
|
||||||
|
id: 11,
|
||||||
|
name: '親タグ',
|
||||||
|
category: 'general',
|
||||||
|
children: [childTag],
|
||||||
|
})
|
||||||
|
const duplicateParentTag = buildTag ({
|
||||||
|
id: 13,
|
||||||
|
name: '別親タグ',
|
||||||
|
category: 'general',
|
||||||
|
children: [childTag],
|
||||||
|
})
|
||||||
|
|
||||||
|
postsApi.fetchPost.mockResolvedValueOnce (buildPost ({
|
||||||
|
...currentPost,
|
||||||
|
tags: [parentTag, duplicateParentTag],
|
||||||
|
}))
|
||||||
|
|
||||||
|
renderPage ()
|
||||||
|
|
||||||
|
await screen.findByText ('Embed:上映中の投稿')
|
||||||
|
|
||||||
|
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' })[0])
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getAllByText ('↳').length).toBeGreaterThan (0)
|
||||||
|
|
||||||
|
fireEvent.click (screen.getByRole ('button', { name: '2 列 A 型' }))
|
||||||
|
fireEvent.click (screen.getAllByRole ('button', { name: '横並び' })[0])
|
||||||
|
|
||||||
|
await waitFor (() => {
|
||||||
|
expect (within (tagSection ()).getByRole ('link', { name: '親タグ' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getByRole ('link', { name: '別親タグ' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getByRole ('link', { name: '子タグ' }))
|
||||||
|
.toBeInTheDocument ()
|
||||||
|
expect (within (tagSection ()).getAllByRole ('link', { name: '子タグ' }))
|
||||||
|
.toHaveLength (1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it ('does not advance host post while video length is unknown', async () => {
|
it ('does not advance host post while video length is unknown', async () => {
|
||||||
api.apiPut.mockImplementation ((path: string) => {
|
api.apiPut.mockImplementation ((path: string) => {
|
||||||
switch (path)
|
switch (path)
|
||||||
|
|||||||
@@ -102,6 +102,28 @@ const compareTagName = (a: Tag, b: Tag): number =>
|
|||||||
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
||||||
|
|
||||||
|
|
||||||
|
const flattenTags = (tags: Tag[]): Tag[] => {
|
||||||
|
const flattened: Tag[] = []
|
||||||
|
const seen = new Set<number> ()
|
||||||
|
|
||||||
|
const visit = (tag: Tag) => {
|
||||||
|
if (seen.has (tag.id))
|
||||||
|
return
|
||||||
|
|
||||||
|
seen.add (tag.id)
|
||||||
|
flattened.push (tag)
|
||||||
|
|
||||||
|
for (const child of tag.children ?? [])
|
||||||
|
visit (child)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tag of tags)
|
||||||
|
visit (tag)
|
||||||
|
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
||||||
const grouped: Partial<Record<Category, Tag[]>> = { }
|
const grouped: Partial<Record<Category, Tag[]>> = { }
|
||||||
|
|
||||||
@@ -118,15 +140,39 @@ const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const renderReadonlyTagTree = (
|
||||||
|
tag: Tag,
|
||||||
|
nestLevel: number,
|
||||||
|
path: string,
|
||||||
|
ancestors: Set<number> = new Set<number> (),
|
||||||
|
): ReactNode[] => {
|
||||||
|
const key = `${ path }-${ tag.id }`
|
||||||
|
const nextAncestors = new Set (ancestors)
|
||||||
|
nextAncestors.add (tag.id)
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
<li key={key} className="text-left leading-tight">
|
||||||
|
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
|
||||||
|
</li>),
|
||||||
|
...((tag.children ?? [])
|
||||||
|
.filter (child => !(nextAncestors.has (child.id)))
|
||||||
|
.sort (compareTagName)
|
||||||
|
.flatMap (child =>
|
||||||
|
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||||
{ tags, compact, flow = 'vertical' }) => {
|
{ tags, compact, flow = 'vertical' }) => {
|
||||||
const grouped = tagsByCategory (tags)
|
const horizontalGrouped = tagsByCategory (flattenTags (tags))
|
||||||
|
const verticalGrouped = tagsByCategory (tags)
|
||||||
|
|
||||||
if (flow === 'horizontal')
|
if (flow === 'horizontal')
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
||||||
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
{CATEGORIES.flatMap (cat => horizontalGrouped[cat] ?? []).map (tag => (
|
||||||
<li key={tag.id} className="text-left leading-tight">
|
<li key={tag.id} className="text-left leading-tight">
|
||||||
<TagLink tag={tag} withCount={false}/>
|
<TagLink tag={tag} withCount={false}/>
|
||||||
</li>))}
|
</li>))}
|
||||||
@@ -136,7 +182,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{CATEGORIES.map (cat => {
|
{CATEGORIES.map (cat => {
|
||||||
const rows = grouped[cat] ?? []
|
const rows = verticalGrouped[cat] ?? []
|
||||||
if (rows.length === 0)
|
if (rows.length === 0)
|
||||||
return null
|
return null
|
||||||
|
|
||||||
@@ -146,10 +192,8 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
{CATEGORY_NAMES[cat]}
|
{CATEGORY_NAMES[cat]}
|
||||||
</div>
|
</div>
|
||||||
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
||||||
{rows.map (tag => (
|
{rows.flatMap (tag =>
|
||||||
<li key={tag.id} className="text-left leading-tight">
|
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
|
||||||
<TagLink tag={tag} withCount={false}/>
|
|
||||||
</li>))}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>)
|
</div>)
|
||||||
})}
|
})}
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする