Merge remote-tracking branch 'origin/main' into feature/046 #46 #46 #46 #46 #46 #46 Co-authored-by: miteruzo <miteruzo@naver.com> Reviewed-on: https://git.miteruzo.com/miteruzo/btrc-hub/pulls/339main
| @@ -33,8 +33,8 @@ class NicoTagsController < ApplicationController | |||
| return head :bad_request unless tag.nico? | |||
| linked_tag_names = params[:tags].to_s.split | |||
| linked_tags = Tag.normalise_tags(linked_tag_names, with_tagme: false, | |||
| with_no_deerjikist: false) | |||
| linked_tags = Tag.normalise_tags!(linked_tag_names, with_tagme: false, | |||
| with_no_deerjikist: false) | |||
| return head :bad_request if linked_tags.any? { |t| t.nico? } | |||
| ApplicationRecord.transaction do | |||
| @@ -109,7 +109,7 @@ class PostsController < ApplicationController | |||
| render json: PostRepr.base(post, current_user) | |||
| .merge(tags: build_tag_tree_for(post.tags), | |||
| related: post.related(limit: 20)) | |||
| related: PostRepr.many(post.related(limit: 20))) | |||
| end | |||
| def create | |||
| @@ -123,28 +123,36 @@ class PostsController < ApplicationController | |||
| tag_names = params[:tags].to_s.split | |||
| original_created_from = params[:original_created_from] | |||
| original_created_before = params[:original_created_before] | |||
| parent_post_ids = parse_parent_post_ids | |||
| post = Post.new(title:, url:, thumbnail_base: nil, uploaded_user: current_user, | |||
| original_created_from:, original_created_before:) | |||
| post.thumbnail.attach(thumbnail) | |||
| post.thumbnail.attach(thumbnail) if thumbnail.present? | |||
| ApplicationRecord.transaction do | |||
| post.save! | |||
| tags = Tag.normalise_tags(tag_names) | |||
| tags = Tag.normalise_tags!(tag_names) | |||
| TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| sync_parent_posts!(post, parent_post_ids) | |||
| post.resized_thumbnail! | |||
| PostVersionRecorder.record!(post:, event_type: :create, created_by_user: current_user) | |||
| end | |||
| post.reload | |||
| render json: PostRepr.base(post), status: :created | |||
| rescue ActiveRecord::RecordInvalid | |||
| render json: { errors: post.errors.full_messages }, status: :unprocessable_entity | |||
| rescue Tag::NicoTagNormalisationError | |||
| head :bad_request | |||
| rescue ArgumentError => e | |||
| render json: { errors: [e.message] }, status: :unprocessable_entity | |||
| rescue ActiveRecord::RecordInvalid => e | |||
| render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity | |||
| end | |||
| def viewed | |||
| @@ -169,6 +177,7 @@ class PostsController < ApplicationController | |||
| tag_names = params[:tags].to_s.split | |||
| original_created_from = params[:original_created_from] | |||
| original_created_before = params[:original_created_before] | |||
| parent_post_ids = parse_parent_post_ids | |||
| post = Post.find(params[:id].to_i) | |||
| @@ -177,12 +186,15 @@ class PostsController < ApplicationController | |||
| post.update!(title:, original_created_from:, original_created_before:) | |||
| normalised_tags = Tag.normalise_tags(tag_names, with_tagme: false) | |||
| normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false) | |||
| TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) | |||
| tags = post.tags.nico.to_a + normalised_tags | |||
| tags = Tag.expand_parent_tags(tags) | |||
| sync_post_tags!(post, tags) | |||
| sync_parent_posts!(post, parent_post_ids) | |||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||
| end | |||
| @@ -190,10 +202,12 @@ class PostsController < ApplicationController | |||
| json = post.as_json | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| render json:, status: :ok | |||
| rescue ActiveRecord::RecordInvalid | |||
| render json: post.errors, status: :unprocessable_entity | |||
| rescue Tag::NicoTagNormalisationError | |||
| head :bad_request | |||
| rescue ArgumentError => e | |||
| render json: { errors: [e.message] }, status: :unprocessable_entity | |||
| rescue ActiveRecord::RecordInvalid => e | |||
| render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity | |||
| end | |||
| def changes | |||
| @@ -353,4 +367,41 @@ class PostsController < ApplicationController | |||
| root_ids.filter_map { |id| build_node.call(id, []) } | |||
| end | |||
| def parse_parent_post_ids | |||
| raise ArgumentError, 'parent_post_ids は必須です.' unless params.key?(:parent_post_ids) | |||
| params[:parent_post_ids].to_s.split.map { |token| | |||
| id = Integer(token, exception: false) | |||
| raise ArgumentError, "親投稿 Id. が不正です: #{ token }" if id.nil? || id <= 0 | |||
| id | |||
| }.uniq | |||
| end | |||
| def sync_parent_posts! post, parent_post_ids | |||
| if parent_post_ids.include?(post.id) | |||
| post.errors.add(:base, '自分自身を親投稿にはできません.') | |||
| raise ActiveRecord::RecordInvalid, post | |||
| end | |||
| existing_ids = Post.where(id: parent_post_ids).pluck(:id) | |||
| missing_ids = parent_post_ids - existing_ids | |||
| if missing_ids.present? | |||
| post.errors.add(:base, "存在しない親投稿 ID があります: #{ missing_ids.join(' ') }") | |||
| raise ActiveRecord::RecordInvalid, post | |||
| end | |||
| current_ids = post.parent_posts.pluck(:id) | |||
| ids_to_add = parent_post_ids - current_ids | |||
| ids_to_remove = current_ids - parent_post_ids | |||
| PostImplication.where(post_id: post.id, parent_post_id: ids_to_remove).delete_all | |||
| ids_to_add.each do |parent_post_id| | |||
| PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) | |||
| end | |||
| end | |||
| end | |||
| @@ -374,9 +374,9 @@ class TagsController < ApplicationController | |||
| end | |||
| def update_parent_tags! tag, parent_names | |||
| parent_tags = Tag.normalise_tags(parent_names, with_tagme: false, | |||
| with_no_deerjikist: false, | |||
| deny_nico: true) | |||
| parent_tags = Tag.normalise_tags!(parent_names, with_tagme: false, | |||
| with_no_deerjikist: false, | |||
| deny_nico: true) | |||
| old_parent_tags = tag.parents.to_a | |||
| @@ -1,7 +1,6 @@ | |||
| class Post < ApplicationRecord | |||
| require 'mini_magick' | |||
| belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' | |||
| belongs_to :uploaded_user, class_name: 'User', optional: true | |||
| has_many :post_tags, dependent: :destroy, inverse_of: :post | |||
| @@ -13,6 +12,20 @@ class Post < ApplicationRecord | |||
| has_many :post_similarities, dependent: :delete_all | |||
| has_many :post_versions | |||
| has_many :parent_post_implications, | |||
| class_name: 'PostImplication', | |||
| foreign_key: :post_id, | |||
| dependent: :destroy, | |||
| inverse_of: :post | |||
| has_many :parents, through: :parent_post_implications, source: :parent_post | |||
| has_many :child_post_implications, | |||
| class_name: 'PostImplication', | |||
| foreign_key: :parent_post_id, | |||
| dependent: :destroy, | |||
| inverse_of: :parent_post | |||
| has_many :children, through: :child_post_implications, source: :post | |||
| has_one_attached :thumbnail | |||
| before_validation :normalise_url | |||
| @@ -22,17 +35,29 @@ class Post < ApplicationRecord | |||
| validate :validate_original_created_range | |||
| validate :url_must_be_http_url | |||
| def parent_posts = parents | |||
| def child_posts = children | |||
| def sibling_posts | |||
| parent_post_ids = parent_posts.order(:id).pluck(:id) | |||
| parent_post_ids.to_h { [_1, PostImplication.where(parent_post: _1).map(&:post)] } | |||
| end | |||
| def as_json options = { } | |||
| super(options).merge({ thumbnail: thumbnail.attached? ? | |||
| Rails.application.routes.url_helpers.rails_blob_url( | |||
| thumbnail, only_path: false) : | |||
| nil }) | |||
| super(options).merge(thumbnail: thumbnail.attached? ? | |||
| Rails.application.routes.url_helpers.rails_blob_url( | |||
| thumbnail, only_path: false) : | |||
| nil) | |||
| rescue | |||
| super(options).merge(thumbnail: nil) | |||
| end | |||
| def snapshot_tag_names = tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||
| def snapshot_parent_post_ids = parents.order(:id).pluck(:id) | |||
| def related limit: nil | |||
| ids = post_similarities.order(cos: :desc) | |||
| ids = ids.limit(limit) if limit | |||
| @@ -0,0 +1,19 @@ | |||
| class PostImplication < ApplicationRecord | |||
| self.primary_key = :post_id, :parent_post_id | |||
| belongs_to :post, inverse_of: :parent_post_implications | |||
| belongs_to :parent_post, class_name: 'Post', inverse_of: :child_post_implications | |||
| validates :post_id, presence: true, uniqueness: { scope: :parent_post_id } | |||
| validates :parent_post_id, presence: true | |||
| validate :parent_post_mustnt_be_itself | |||
| private | |||
| def parent_post_mustnt_be_itself | |||
| if parent_post_id == post_id | |||
| errors.add :parent_post_id, '親投稿に同じ投稿を設定することはできません.' | |||
| end | |||
| end | |||
| end | |||
| @@ -86,9 +86,9 @@ class Tag < ApplicationRecord | |||
| def self.niconico = find_or_create_by_tag_name!('ニコニコ', category: :meta) | |||
| def self.youtube = find_or_create_by_tag_name!('YouTube', category: :meta) | |||
| def self.normalise_tags tag_names, with_tagme: true, | |||
| with_no_deerjikist: true, | |||
| deny_nico: true | |||
| def self.normalise_tags! tag_names, with_tagme: true, | |||
| with_no_deerjikist: true, | |||
| deny_nico: true | |||
| if deny_nico && tag_names.any? { |n| n.downcase.start_with?('nico:') } | |||
| raise NicoTagNormalisationError | |||
| end | |||
| @@ -2,7 +2,8 @@ | |||
| module PostRepr | |||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE } }.freeze | |||
| BASE = { include: { tags: TagRepr::BASE, uploaded_user: UserRepr::BASE }, | |||
| methods: [:parent_posts, :child_posts, :sibling_posts] }.freeze | |||
| module_function | |||
| @@ -24,7 +24,7 @@ class PostVersionRecorder < VersionRecorder | |||
| url: @record.url, | |||
| thumbnail_base: @record.thumbnail_base, | |||
| tags: @record.snapshot_tag_names.join(' '), | |||
| parent_id: @record.parent_id, | |||
| parent_post_ids: @record.snapshot_parent_post_ids.join(' '), | |||
| original_created_from: @record.original_created_from, | |||
| original_created_before: @record.original_created_before } | |||
| end | |||
| @@ -0,0 +1,24 @@ | |||
| class CreatePostImplications < ActiveRecord::Migration[8.0] | |||
| def up | |||
| create_table :post_implications, primary_key: [:post_id, :parent_post_id] do |t| | |||
| t.references :post, null: false, foreign_key: true, index: false | |||
| t.references :parent_post, null: false, foreign_key: { to_table: :posts } | |||
| t.timestamps | |||
| t.check_constraint 'post_id <> parent_post_id', | |||
| name: 'chk_post_implications_no_self' | |||
| end | |||
| add_column :post_versions, :parent_post_ids, :text, null: false, after: :parent_id | |||
| remove_column :post_versions, :parent_id, :bigint | |||
| remove_reference :posts, :parent, foreign_key: { to_table: :posts } | |||
| end | |||
| def down | |||
| add_reference :posts, :parent, foreign_key: { to_table: :posts }, after: :thumbnail_base | |||
| add_column :post_versions, :parent_id, :bigint, after: :post_id | |||
| remove_column :post_versions, :parent_post_ids, :text | |||
| drop_table :post_implications | |||
| end | |||
| end | |||
| @@ -10,7 +10,7 @@ | |||
| # | |||
| # It's strongly recommended that you check this file into your version control system. | |||
| ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| ActiveRecord::Schema[8.0].define(version: 2026_04_27_214800) do | |||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "name", null: false | |||
| t.string "record_type", null: false | |||
| @@ -119,6 +119,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive" | |||
| end | |||
| create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "post_id", null: false | |||
| t.bigint "parent_post_id", null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" | |||
| t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self" | |||
| end | |||
| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "post_id", null: false | |||
| t.bigint "target_post_id", null: false | |||
| @@ -155,13 +164,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| t.string "url", limit: 768, null: false | |||
| t.string "thumbnail_base", limit: 2000 | |||
| t.text "tags", null: false | |||
| t.bigint "parent_id" | |||
| t.text "parent_post_ids", null: false | |||
| 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" | |||
| @@ -172,13 +180,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| t.string "title" | |||
| t.string "url", limit: 768, null: false | |||
| t.string "thumbnail_base", limit: 2000 | |||
| t.bigint "parent_id" | |||
| t.bigint "uploaded_user_id" | |||
| t.datetime "created_at", null: false | |||
| t.datetime "original_created_from" | |||
| t.datetime "original_created_before" | |||
| t.datetime "updated_at", null: false | |||
| t.index ["parent_id"], name: "index_posts_on_parent_id" | |||
| t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | |||
| t.index ["url"], name: "index_posts_on_url", unique: true | |||
| end | |||
| @@ -428,6 +434,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| add_foreign_key "nico_tag_relations", "tags", column: "nico_tag_id" | |||
| add_foreign_key "nico_tag_versions", "tags" | |||
| add_foreign_key "nico_tag_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "post_implications", "posts" | |||
| add_foreign_key "post_implications", "posts", column: "parent_post_id" | |||
| add_foreign_key "post_similarities", "posts" | |||
| add_foreign_key "post_similarities", "posts", column: "target_post_id" | |||
| add_foreign_key "post_tags", "posts" | |||
| @@ -435,9 +443,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_04_26_120600) do | |||
| add_foreign_key "post_tags", "users", column: "created_user_id" | |||
| add_foreign_key "post_tags", "users", column: "deleted_user_id" | |||
| add_foreign_key "post_versions", "posts" | |||
| add_foreign_key "post_versions", "posts", column: "parent_id" | |||
| add_foreign_key "post_versions", "users", column: "created_by_user_id" | |||
| add_foreign_key "posts", "posts", column: "parent_id" | |||
| add_foreign_key "posts", "users", column: "uploaded_user_id" | |||
| add_foreign_key "settings", "users" | |||
| add_foreign_key "tag_implications", "tags" | |||
| @@ -0,0 +1,51 @@ | |||
| require 'rails_helper' | |||
| RSpec.describe PostImplication, type: :model do | |||
| let!(:post_record) do | |||
| Post.create!( | |||
| title: 'post', | |||
| url: 'https://example.com/post-implication-post' | |||
| ) | |||
| end | |||
| let!(:parent_post) do | |||
| Post.create!( | |||
| title: 'parent post', | |||
| url: 'https://example.com/post-implication-parent' | |||
| ) | |||
| end | |||
| it 'is valid with post and parent_post' do | |||
| implication = described_class.new( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| expect(implication).to be_valid | |||
| end | |||
| it 'does not allow same post as parent_post' do | |||
| implication = described_class.new( | |||
| post: post_record, | |||
| parent_post: post_record | |||
| ) | |||
| expect(implication).not_to be_valid | |||
| expect(implication.errors[:parent_post_id]).to be_present | |||
| end | |||
| it 'does not allow duplicate pair' do | |||
| described_class.create!( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| duplicate = described_class.new( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| expect(duplicate).not_to be_valid | |||
| expect(duplicate.errors[:post_id]).to be_present | |||
| end | |||
| end | |||
| @@ -19,7 +19,7 @@ RSpec.describe PostVersion, type: :model do | |||
| url: post_record.url, | |||
| thumbnail_base: post_record.thumbnail_base, | |||
| tags: post_record.snapshot_tag_names.join(' '), | |||
| parent: post_record.parent, | |||
| parent_post_ids: post_record.snapshot_parent_post_ids.join(' '), | |||
| original_created_from: post_record.original_created_from, | |||
| original_created_before: post_record.original_created_before, | |||
| created_at: Time.current, | |||
| @@ -161,7 +161,7 @@ RSpec.describe Tag, type: :model do | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| parent_post_ids: post.snapshot_parent_post_ids.join(' '), | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: Time.current, | |||
| @@ -15,6 +15,31 @@ RSpec.describe 'Posts API', type: :request do | |||
| Rack::Test::UploadedFile.new(StringIO.new('dummy'), 'image/jpeg', original_filename: 'dummy.jpg') | |||
| end | |||
| def post_write_params params = { } | |||
| { parent_post_ids: '' }.merge(params) | |||
| end | |||
| def create_parent_post! title:, url: | |||
| Post.create!(title:, url:) | |||
| end | |||
| def create_post_version_for! post | |||
| PostVersion.create!( | |||
| post:, | |||
| version_no: 1, | |||
| event_type: 'create', | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: post.snapshot_tag_names.join(' '), | |||
| parent_post_ids: post.snapshot_parent_post_ids.join(' '), | |||
| 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 | |||
| let!(:tag_name) { TagName.create!(name: 'spec_tag') } | |||
| let!(:tag) { Tag.create!(tag_name: tag_name, category: :general) } | |||
| @@ -457,6 +482,65 @@ RSpec.describe 'Posts API', type: :request do | |||
| expect(json).to have_key('viewed') | |||
| expect([true, false]).to include(json['viewed']) | |||
| end | |||
| context 'when post has parent, child, and sibling posts' do | |||
| let!(:parent_post) do | |||
| create_parent_post!( | |||
| title: 'shared parent post', | |||
| url: 'https://example.com/shared-parent-post' | |||
| ) | |||
| end | |||
| let!(:child_post) do | |||
| Post.create!( | |||
| title: 'child post', | |||
| url: 'https://example.com/show-child-post' | |||
| ) | |||
| end | |||
| let!(:sibling_post) do | |||
| Post.create!( | |||
| title: 'sibling post', | |||
| url: 'https://example.com/show-sibling-post' | |||
| ) | |||
| end | |||
| before do | |||
| PostImplication.create!( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| PostImplication.create!( | |||
| post: child_post, | |||
| parent_post: post_record | |||
| ) | |||
| PostImplication.create!( | |||
| post: sibling_post, | |||
| parent_post: | |||
| ) | |||
| end | |||
| it 'returns parent_posts, child_posts, and sibling_posts' do | |||
| get "/posts/#{post_record.id}" | |||
| expect(response).to have_http_status(:ok) | |||
| parent_ids = json.fetch('parent_posts').map { |p| p.fetch('id') } | |||
| child_ids = json.fetch('child_posts').map { |p| p.fetch('id') } | |||
| expect(parent_ids).to include(parent_post.id) | |||
| expect(child_ids).to include(child_post.id) | |||
| sibling_posts_by_parent = json.fetch('sibling_posts') | |||
| siblings = sibling_posts_by_parent.fetch(parent_post.id.to_s) | |||
| sibling_ids = siblings.map { |p| p.fetch('id') } | |||
| expect(sibling_ids).to include(post_record.id) | |||
| expect(sibling_ids).to include(sibling_post.id) | |||
| end | |||
| end | |||
| end | |||
| context 'when post does not exist' do | |||
| @@ -475,25 +559,28 @@ RSpec.describe 'Posts API', type: :request do | |||
| it '401 when not logged in' do | |||
| sign_out | |||
| post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } | |||
| post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a', | |||
| thumbnail: dummy_upload) | |||
| expect(response).to have_http_status(:unauthorized) | |||
| end | |||
| it '403 when not member' do | |||
| sign_in_as(create(:user, role: 'guest')) | |||
| post '/posts', params: { title: 't', url: 'https://example.com/x', tags: 'a', thumbnail: dummy_upload } | |||
| post '/posts', params: post_write_params(title: 't', url: 'https://example.com/x', tags: 'a', | |||
| thumbnail: dummy_upload) | |||
| expect(response).to have_http_status(:forbidden) | |||
| end | |||
| it '201 and creates post + tags when member' do | |||
| sign_in_as(member) | |||
| post '/posts', params: { | |||
| post '/posts', params: post_write_params( | |||
| title: 'new post', | |||
| url: 'https://example.com/new', | |||
| tags: 'spec_tag', # 既存タグ名を投げる | |||
| thumbnail: dummy_upload | |||
| } | |||
| ) | |||
| expect(response).to have_http_status(:created) | |||
| expect(json).to include('id', 'title', 'url') | |||
| @@ -507,12 +594,12 @@ RSpec.describe 'Posts API', type: :request do | |||
| it '201 and creates post + tags when member and tags have aliases' do | |||
| sign_in_as(member) | |||
| post '/posts', params: { | |||
| post '/posts', params: post_write_params( | |||
| title: 'new post', | |||
| url: 'https://example.com/new', | |||
| tags: 'manko', # 既存タグ名を投げる | |||
| thumbnail: dummy_upload | |||
| } | |||
| ) | |||
| expect(response).to have_http_status(:created) | |||
| expect(json).to include('id', 'title', 'url') | |||
| @@ -533,13 +620,14 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'return 400' do | |||
| sign_in_as(member) | |||
| post '/posts', params: { | |||
| title: 'new post', | |||
| url: 'https://example.com/nico_tag', | |||
| tags: 'nico:nico_tag', | |||
| thumbnail: dummy_upload } | |||
| post '/posts', params: post_write_params( | |||
| title: 'new post', | |||
| url: 'https://example.com/nico-tag-post', | |||
| tags: 'nico:nico_tag', | |||
| thumbnail: dummy_upload | |||
| ) | |||
| expect(response).to have_http_status(:bad_request) | |||
| expect(response).to have_http_status(:bad_request), response.body | |||
| end | |||
| end | |||
| @@ -547,11 +635,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'returns 422' do | |||
| sign_in_as(member) | |||
| post '/posts', params: { | |||
| post '/posts', params: post_write_params( | |||
| title: 'new post', | |||
| url: ' ', | |||
| tags: 'spec_tag', # 既存タグ名を投げる | |||
| thumbnail: dummy_upload } | |||
| thumbnail: dummy_upload) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| end | |||
| @@ -561,14 +649,154 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'returns 422' do | |||
| sign_in_as(member) | |||
| post '/posts', params: { | |||
| title: 'new post', | |||
| url: 'ぼざクリタグ広場', | |||
| tags: 'spec_tag', # 既存タグ名を投げる | |||
| post '/posts', params: post_write_params( | |||
| title: 'new post', | |||
| url: 'ぼざクリタグ広場', | |||
| tags: 'spec_tag', # 既存タグ名を投げる | |||
| thumbnail: dummy_upload) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| end | |||
| end | |||
| context 'when parent_post_ids is provided' do | |||
| let!(:parent_post_1) do | |||
| create_parent_post!( | |||
| title: 'parent post 1', | |||
| url: 'https://example.com/parent-post-1' | |||
| ) | |||
| end | |||
| let!(:parent_post_2) do | |||
| create_parent_post!( | |||
| title: 'parent post 2', | |||
| url: 'https://example.com/parent-post-2' | |||
| ) | |||
| end | |||
| it 'creates post implications for parent posts' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: { | |||
| title: 'child post', | |||
| url: 'https://example.com/child-post', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}", | |||
| thumbnail: dummy_upload } | |||
| }.to change(PostImplication, :count).by(2) | |||
| expect(response).to have_http_status(:created) | |||
| created_post = Post.find(json.fetch('id')) | |||
| expect(created_post.parent_posts.order(:id).pluck(:id)).to eq( | |||
| [parent_post_1.id, parent_post_2.id].sort | |||
| ) | |||
| expect(PostImplication.exists?( | |||
| post_id: created_post.id, | |||
| parent_post_id: parent_post_1.id | |||
| )).to be(true) | |||
| expect(PostImplication.exists?( | |||
| post_id: created_post.id, | |||
| parent_post_id: parent_post_2.id | |||
| )).to be(true) | |||
| end | |||
| it 'deduplicates parent_post_ids' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: post_write_params( | |||
| title: 'dedup child post', | |||
| url: 'https://example.com/dedup-child-post', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: "#{parent_post_1.id} #{parent_post_1.id}", | |||
| thumbnail: dummy_upload | |||
| ) | |||
| }.to change(PostImplication, :count).by(1) | |||
| expect(response).to have_http_status(:created) | |||
| created_post = Post.find(json.fetch('id')) | |||
| expect(created_post.parent_posts.pluck(:id)).to eq([parent_post_1.id]) | |||
| end | |||
| it 'records parent_post_ids in post version' do | |||
| sign_in_as(member) | |||
| post '/posts', params: post_write_params( | |||
| title: 'versioned child post', | |||
| url: 'https://example.com/versioned-child-post', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: "#{parent_post_1.id} #{parent_post_2.id}", | |||
| thumbnail: dummy_upload | |||
| } | |||
| ) | |||
| 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.parent_post_ids.split.map(&:to_i)).to eq( | |||
| [parent_post_1.id, parent_post_2.id].sort | |||
| ) | |||
| end | |||
| end | |||
| context 'when parent_post_ids is missing' do | |||
| it 'returns 422' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: { | |||
| title: 'missing parent_post_ids', | |||
| url: 'https://example.com/missing-parent-post-ids', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload } | |||
| }.not_to change(Post, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(json.fetch('errors')).to be_present | |||
| end | |||
| end | |||
| context 'when parent_post_ids includes invalid token' do | |||
| it 'returns 422 and does not create post' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: post_write_params( | |||
| title: 'invalid parent ids', | |||
| url: 'https://example.com/invalid-parent-ids', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: 'abc', | |||
| thumbnail: dummy_upload | |||
| ) | |||
| }.not_to change(Post, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(json.fetch('errors')).to be_present | |||
| end | |||
| end | |||
| context 'when parent_post_ids includes nonexistent post id' do | |||
| it 'returns 422 and does not create post implication' do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: post_write_params( | |||
| title: 'missing parent post', | |||
| url: 'https://example.com/missing-parent-post', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: '999999999', | |||
| thumbnail: dummy_upload | |||
| ) | |||
| }.not_to change(PostImplication, :count) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(json.fetch('errors')).to be_present | |||
| end | |||
| end | |||
| end | |||
| @@ -578,13 +806,13 @@ RSpec.describe 'Posts API', type: :request do | |||
| it '401 when not logged in' do | |||
| sign_out | |||
| put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } | |||
| put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') | |||
| expect(response).to have_http_status(:unauthorized) | |||
| end | |||
| it '403 when not member' do | |||
| sign_in_as(create(:user, role: 'guest')) | |||
| put "/posts/#{post_record.id}", params: { title: 'updated', tags: 'spec_tag' } | |||
| put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') | |||
| expect(response).to have_http_status(:forbidden) | |||
| end | |||
| @@ -595,10 +823,9 @@ RSpec.describe 'Posts API', type: :request do | |||
| tn2 = TagName.create!(name: 'spec_tag_2') | |||
| Tag.create!(tag_name: tn2, category: :general) | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2' | |||
| } | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2') | |||
| expect(response).to have_http_status(:ok) | |||
| expect(json).to have_key('tags') | |||
| @@ -619,11 +846,178 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'return 400' do | |||
| sign_in_as(member) | |||
| put "/posts/#{ post_record.id }", params: { | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'nico:nico_tag' | |||
| ) | |||
| expect(response).to have_http_status(:bad_request), response.body | |||
| end | |||
| end | |||
| context 'when parent_post_ids is provided' do | |||
| let!(:old_parent_post) do | |||
| create_parent_post!( | |||
| title: 'old parent post', | |||
| url: 'https://example.com/old-parent-post' | |||
| ) | |||
| end | |||
| let!(:new_parent_post_1) do | |||
| create_parent_post!( | |||
| title: 'new parent post 1', | |||
| url: 'https://example.com/new-parent-post-1' | |||
| ) | |||
| end | |||
| let!(:new_parent_post_2) do | |||
| create_parent_post!( | |||
| title: 'new parent post 2', | |||
| url: 'https://example.com/new-parent-post-2' | |||
| ) | |||
| end | |||
| before do | |||
| PostImplication.create!( | |||
| post: post_record, | |||
| parent_post: old_parent_post | |||
| ) | |||
| end | |||
| it 'replaces parent posts' do | |||
| sign_in_as(member) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" | |||
| ) | |||
| expect(response).to have_http_status(:ok) | |||
| expect(post_record.reload.parent_posts.order(:id).pluck(:id)).to eq( | |||
| [new_parent_post_1.id, new_parent_post_2.id].sort | |||
| ) | |||
| expect(PostImplication.exists?( | |||
| post_id: post_record.id, | |||
| parent_post_id: old_parent_post.id | |||
| )).to be(false) | |||
| end | |||
| it 'clears parent posts when parent_post_ids is blank' do | |||
| sign_in_as(member) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: '' | |||
| ) | |||
| expect(response).to have_http_status(:ok) | |||
| expect(post_record.reload.parent_posts).to be_empty | |||
| end | |||
| it 'records changed parent_post_ids in post version' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record.reload) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" | |||
| ) | |||
| 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.parent_post_ids.split.map(&:to_i)).to eq( | |||
| [new_parent_post_1.id, new_parent_post_2.id].sort | |||
| ) | |||
| end | |||
| end | |||
| context 'when parent_post_ids is missing' do | |||
| it 'returns 422' do | |||
| sign_in_as(member) | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'nico:nico_tag' } | |||
| tags: 'spec_tag' } | |||
| expect(response).to have_http_status(:bad_request) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(json.fetch('errors')).to be_present | |||
| end | |||
| end | |||
| context 'when parent_post_ids includes invalid token' do | |||
| it 'returns 422 and does not change parent posts' do | |||
| sign_in_as(member) | |||
| parent_post = create_parent_post!( | |||
| title: 'valid parent post', | |||
| url: 'https://example.com/valid-parent-post' | |||
| ) | |||
| PostImplication.create!( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: 'abc' | |||
| ) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id]) | |||
| end | |||
| end | |||
| context 'when parent_post_ids includes nonexistent post id' do | |||
| it 'returns 422 and does not change parent posts' do | |||
| sign_in_as(member) | |||
| parent_post = create_parent_post!( | |||
| title: 'existing parent post', | |||
| url: 'https://example.com/existing-parent-post' | |||
| ) | |||
| PostImplication.create!( | |||
| post: post_record, | |||
| parent_post: | |||
| ) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: '999999999' | |||
| ) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(post_record.reload.parent_posts.pluck(:id)).to eq([parent_post.id]) | |||
| end | |||
| end | |||
| context 'when parent_post_ids includes self id' do | |||
| it 'returns 422 and does not create self implication' do | |||
| sign_in_as(member) | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: post_record.id.to_s | |||
| ) | |||
| expect(response).to have_http_status(:unprocessable_entity) | |||
| expect(PostImplication.exists?( | |||
| post_id: post_record.id, | |||
| parent_post_id: post_record.id | |||
| )).to be(false) | |||
| end | |||
| end | |||
| end | |||
| @@ -773,20 +1167,20 @@ RSpec.describe 'Posts API', type: :request do | |||
| post.snapshot_tag_names.join(' ') | |||
| end | |||
| def create_post_version!(post, version_no:, event_type:, created_by_user:, created_at:) | |||
| def create_post_version! post, version_no:, event_type:, created_by_user:, created_at: | |||
| PostVersion.create!( | |||
| post: post, | |||
| version_no: version_no, | |||
| event_type: event_type, | |||
| post:, | |||
| version_no:, | |||
| event_type:, | |||
| title: post.title, | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| parent_post_ids: post.snapshot_parent_post_ids.join(' '), | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: created_at, | |||
| created_by_user: created_by_user | |||
| created_at:, | |||
| created_by_user: | |||
| ) | |||
| end | |||
| @@ -1015,33 +1409,15 @@ RSpec.describe 'Posts API', type: :request do | |||
| 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 | |||
| } | |||
| post '/posts', params: post_write_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) | |||
| @@ -1064,10 +1440,9 @@ RSpec.describe 'Posts API', type: :request do | |||
| Tag.create!(tag_name: tag_name2, category: :general) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2' | |||
| } | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2') | |||
| end.to change(PostVersion, :count).by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| @@ -1087,10 +1462,9 @@ RSpec.describe 'Posts API', type: :request do | |||
| create_post_version_for!(post_record.reload) | |||
| expect { | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: post_record.title, | |||
| tags: 'spec_tag' | |||
| } | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: post_record.title, | |||
| tags: 'spec_tag') | |||
| }.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:ok) | |||
| @@ -1104,12 +1478,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| sign_in_as(member) | |||
| expect do | |||
| post '/posts', params: { | |||
| title: 'invalid post', | |||
| url: 'ぼざクリタグ広場', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload | |||
| } | |||
| post '/posts', params: post_write_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) | |||
| @@ -1120,12 +1493,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| 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 | |||
| } | |||
| put "/posts/#{post_record.id}", params: post_write_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) | |||
| @@ -1139,12 +1511,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| sign_in_as(member) | |||
| expect { | |||
| post '/posts', params: { | |||
| title: 'tag versioned post', | |||
| url: 'https://example.com/tag-versioned-post', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload | |||
| } | |||
| post '/posts', params: post_write_params( | |||
| title: 'tag versioned post', | |||
| url: 'https://example.com/tag-versioned-post', | |||
| tags: 'spec_tag', | |||
| thumbnail: dummy_upload) | |||
| }.to change { tag.reload.tag_versions.count }.by(1) | |||
| expect(response).to have_http_status(:created) | |||
| @@ -1164,10 +1535,9 @@ RSpec.describe 'Posts API', type: :request do | |||
| tag2 = Tag.create!(tag_name: tag_name2, category: :general) | |||
| expect { | |||
| put "/posts/#{post_record.id}", params: { | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2' | |||
| } | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2') | |||
| }.to change { tag2.reload.tag_versions.count }.by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| @@ -104,7 +104,7 @@ RSpec.describe "nico:sync" do | |||
| url: post.url, | |||
| thumbnail_base: post.thumbnail_base, | |||
| tags: snapshot_tags(post), | |||
| parent: post.parent, | |||
| parent_post_ids: post.snapshot_parent_post_ids.join(' '), | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: Time.current, | |||
| @@ -4,6 +4,7 @@ import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||
| import Label from '@/components/common/Label' | |||
| import { Button } from '@/components/ui/button' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { apiPut } from '@/lib/api' | |||
| import type { FC } from 'react' | |||
| @@ -35,20 +36,34 @@ export default (({ post, onSave }: Props) => { | |||
| useState<string | null> (post.originalCreatedBefore) | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | |||
| useState<string | null> (post.originalCreatedFrom) | |||
| const [title, setTitle] = useState (post.title) | |||
| const [parentPostIds, setParentPostIds] = | |||
| useState ((post.parentPosts ?? []).map (p => p.id).join (' ')) | |||
| const [tags, setTags] = useState<string> ('') | |||
| const [title, setTitle] = useState (post.title) | |||
| const handleSubmit = async () => { | |||
| const data = await apiPut<Post> ( | |||
| `/posts/${ post.id }`, | |||
| { title, tags, original_created_from: originalCreatedFrom, | |||
| original_created_before: originalCreatedBefore }, | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||
| onSave ({ ...post, | |||
| title: data.title, | |||
| tags: data.tags, | |||
| originalCreatedFrom: data.originalCreatedFrom, | |||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||
| try | |||
| { | |||
| const data = await apiPut<Post> ( | |||
| `/posts/${ post.id }`, | |||
| { title, tags, parent_post_ids: parentPostIds, | |||
| original_created_from: originalCreatedFrom, | |||
| original_created_before: originalCreatedBefore }, | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||
| onSave ({ ...post, | |||
| title: data.title, | |||
| tags: data.tags, | |||
| parentPosts: data.parentPosts, | |||
| childPosts: data.childPosts, | |||
| siblingPosts: data.siblingPosts, | |||
| originalCreatedFrom: data.originalCreatedFrom, | |||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||
| toast ({ description: '更新しました.' }) | |||
| } | |||
| catch | |||
| { | |||
| toast ({ description: '更新はできなかったよ……' }) | |||
| } | |||
| } | |||
| useEffect (() => { | |||
| @@ -66,6 +81,16 @@ export default (({ post, onSave }: Props) => { | |||
| onChange={ev => setTitle (ev.target.value)}/> | |||
| </div> | |||
| {/* 親投稿 */} | |||
| <div> | |||
| <Label>親投稿</Label> | |||
| <input | |||
| type="text" | |||
| value={parentPostIds} | |||
| onChange={e => setParentPostIds (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* タグ */} | |||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||
| @@ -3,6 +3,7 @@ import { useRef } from 'react' | |||
| import { useLocation } from 'react-router-dom' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import { cn } from '@/lib/utils' | |||
| import { useSharedTransitionStore } from '@/stores/sharedTransitionStore' | |||
| import type { FC, MouseEvent } from 'react' | |||
| @@ -39,8 +40,10 @@ export default (({ posts, onClick }: Props) => { | |||
| <motion.div | |||
| ref={cardRef} | |||
| layoutId={layoutId} | |||
| className="w-full h-full overflow-hidden rounded-xl shadow | |||
| transform-gpu will-change-transform" | |||
| className={cn ('w-full h-full overflow-hidden rounded-xl shadow', | |||
| 'transform-gpu will-change-transform', | |||
| (post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500', | |||
| (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} | |||
| whileHover={{ scale: 1.02 }} | |||
| onLayoutAnimationStart={() => { | |||
| if (!(cardRef.current)) | |||
| @@ -21,7 +21,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable' | |||
| import type { FC } from 'react' | |||
| import type { NiconicoViewerHandle, User } from '@/types' | |||
| import type { NiconicoViewerHandle, Post, User } from '@/types' | |||
| type Props = { user: User | null } | |||
| @@ -108,6 +108,34 @@ export default (({ user }: Props) => { | |||
| {post | |||
| ? ( | |||
| <> | |||
| {(post.childPosts ?? []).length > 0 && ( | |||
| <div className="mb-4 bg-green-200 dark:bg-green-800 text-sm p-2 rounded-md"> | |||
| <p>この投稿には {post.childPosts!.length} 件の子投稿があります.</p> | |||
| <PostList posts={[{ ...post, childPosts: [{ } as Post] }, | |||
| ...post.childPosts!.map (p => ({ | |||
| ...p, parentPosts: [{ } as Post] }))]}/> | |||
| </div> | |||
| )} | |||
| {(post.parentPosts ?? []).map (pp => { | |||
| const siblings = post.siblingPosts?.[String (pp.id) as `${ number }`] | |||
| if (!(siblings)) | |||
| return | |||
| return ( | |||
| <div | |||
| key={pp.id} | |||
| className="mb-4 bg-yellow-200 dark:bg-yellow-800 text-sm p-2 rounded-md"> | |||
| <p> | |||
| この投稿には 1 件の親投稿{ | |||
| siblings.length > 1 | |||
| && `と ${ siblings.length - 1 } 件の姉妹投稿`}があります. | |||
| </p> | |||
| <PostList posts={[{ ...pp, childPosts: [{ } as Post] }, | |||
| ...siblings.map (p => ({ | |||
| ...p, parentPosts: [{ } as Post] }))]}/> | |||
| </div>) | |||
| })} | |||
| {(post.thumbnail || post.thumbnailBase) && ( | |||
| <motion.div | |||
| layoutId={`page-${ id }`} | |||
| @@ -146,7 +174,6 @@ export default (({ user }: Props) => { | |||
| (prev: any) => newPost ?? prev) | |||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||
| toast ({ description: '更新しました.' }) | |||
| }}/> | |||
| </Tab>)} | |||
| </TabGroup> | |||
| @@ -95,6 +95,8 @@ export default (() => { | |||
| <col className="w-96"/> | |||
| {/* タグ */} | |||
| <col className="w-[48rem]"/> | |||
| {/* TODO: 親投稿 */} | |||
| {/* <col className="w-[48rem]"/> */} | |||
| {/* オリジナルの投稿日時 */} | |||
| <col className="w-96"/> | |||
| {/* 更新日時 */} | |||
| @@ -110,6 +112,8 @@ export default (() => { | |||
| <th className="p-2 text-left">タイトル</th> | |||
| <th className="p-2 text-left">URL</th> | |||
| <th className="p-2 text-left">タグ</th> | |||
| {/* TODO: 親投稿の履歴 */} | |||
| {/* <th className="p-2 text-left">親投稿</th> */} | |||
| <th className="p-2 text-left">オリジナルの投稿日時</th> | |||
| <th className="p-2 text-left">更新日時</th> | |||
| <th className="p-2"/> | |||
| @@ -180,6 +184,29 @@ export default (() => { | |||
| {tag.name} | |||
| </span>))))} | |||
| </td> | |||
| {/* TODO: 親投稿の履歴 */} | |||
| {/* <td className="p-2"> | |||
| {change.parentPosts.map ((pp, i) => ( | |||
| pp.type === 'added' | |||
| ? ( | |||
| <ins | |||
| key={i} | |||
| className="mr-2 text-green-600 dark:text-green-400"> | |||
| {pp.title} | |||
| </ins>) | |||
| : ( | |||
| pp.type === 'removed' | |||
| ? ( | |||
| <del | |||
| key={i} | |||
| className="mr-2 text-red-600 dark:text-red-400"> | |||
| {pp.title} | |||
| </del>) | |||
| : ( | |||
| <span key={i} className="mr-2"> | |||
| {pp.title} | |||
| </span>))))} | |||
| </td> */} | |||
| <td className="p-2"> | |||
| {change.versionNo === 1 | |||
| ? originalCreatedAtString (change.originalCreatedFrom.current, | |||
| @@ -225,6 +252,11 @@ export default (() => { | |||
| .map (t => t.name) | |||
| .filter (t => t.slice (0, 5) !== 'nico:') | |||
| .join (' '), | |||
| parent_post_ids: | |||
| (change.parentPosts ?? []) | |||
| .filter (p => p.type !== 'removed') | |||
| .map (p => p.id) | |||
| .join (' '), | |||
| original_created_from: | |||
| change.originalCreatedFrom.current, | |||
| original_created_before: | |||
| @@ -29,6 +29,7 @@ export default (({ user }: Props) => { | |||
| const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | |||
| const [parentPostIds, setParentPostIds] = useState ('') | |||
| const [tags, setTags] = useState ('') | |||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||
| @@ -46,6 +47,7 @@ export default (({ user }: Props) => { | |||
| formData.append ('title', title) | |||
| formData.append ('url', url) | |||
| formData.append ('tags', tags) | |||
| formData.append ('parent_post_ids', parentPostIds) | |||
| if (thumbnailFile) | |||
| formData.append ('thumbnail', thumbnailFile) | |||
| if (originalCreatedFrom) | |||
| @@ -177,6 +179,16 @@ export default (({ user }: Props) => { | |||
| className="mt-2 max-h-48 rounded border"/>)} | |||
| </div> | |||
| {/* 親投稿 */} | |||
| <div> | |||
| <Label>親投稿</Label> | |||
| <input | |||
| type="text" | |||
| value={parentPostIds} | |||
| onChange={e => setParentPostIds (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* タグ */} | |||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||
| @@ -121,6 +121,9 @@ export type Post = { | |||
| thumbnail: string | null | |||
| thumbnailBase: string | null | |||
| tags: Tag[] | |||
| parentPosts?: Post[] | |||
| childPosts?: Post[] | |||
| siblingPosts?: Record<`${ number }`, Post[]> | |||
| viewed: boolean | |||
| related: Post[] | |||
| originalCreatedFrom: string | null | |||
| @@ -144,7 +147,11 @@ export type PostVersion = { | |||
| url: { current: string; prev: string | null } | |||
| thumbnail: { current: string | null; prev: string | null } | |||
| thumbnailBase: { current: string | null; prev: string | null } | |||
| tags: { name: string; type: 'context' | 'added' | 'removed' }[] | |||
| tags: { name: string | |||
| type: 'context' | 'added' | 'removed' }[] | |||
| parentPosts: { id: number | |||
| title: string | |||
| type: 'context' | 'added' | 'removed' }[] | |||
| originalCreatedFrom: { current: string | null; prev: string | null } | |||
| originalCreatedBefore: { current: string | null; prev: string | null } | |||
| createdAt: string | |||