| @@ -44,7 +44,7 @@ class PostsController < ApplicationController | |||||
| filtered_posts | filtered_posts | ||||
| .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") | .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") | ||||
| .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) | .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) | ||||
| .preload(tags: [:materials, { tag_name: :wiki_page }]) | |||||
| .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||||
| .with_attached_thumbnail | .with_attached_thumbnail | ||||
| q = q.where('posts.url LIKE ?', "%#{ url }%") if url | q = q.where('posts.url LIKE ?', "%#{ url }%") if url | ||||
| @@ -95,7 +95,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def random | def random | ||||
| post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) | |||||
| post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||||
| .order('RAND()') | .order('RAND()') | ||||
| .first | .first | ||||
| return head :not_found unless post | return head :not_found unless post | ||||
| @@ -104,7 +104,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def show | def show | ||||
| post = Post.includes(tags: [:materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) | |||||
| post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) | |||||
| return head :not_found unless post | return head :not_found unless post | ||||
| render json: PostRepr.base(post, current_user) | render json: PostRepr.base(post, current_user) | ||||
| @@ -173,33 +173,68 @@ class PostsController < ApplicationController | |||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.gte_member? | return head :forbidden unless current_user.gte_member? | ||||
| force = bool?(:force) | |||||
| merge = bool?(:merge) | |||||
| return head :bad_request if force && merge | |||||
| base_version_no = parse_base_version_no | |||||
| return head :bad_request if !(force) && !(base_version_no) | |||||
| title = params[:title].presence | title = params[:title].presence | ||||
| tag_names = params[:tags].to_s.split | tag_names = params[:tags].to_s.split | ||||
| 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 | ||||
| post = Post.find(params[:id].to_i) | |||||
| post = nil | |||||
| conflict_json = nil | |||||
| ApplicationRecord.transaction do | ApplicationRecord.transaction do | ||||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) | |||||
| post.update!(title:, original_created_from:, original_created_before:) | |||||
| normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false) | |||||
| TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user) | |||||
| tags = post.tags.nico.to_a + normalised_tags | |||||
| tags = Tag.expand_parent_tags(tags) | |||||
| sync_post_tags!(post, tags) | |||||
| post = Post.lock.find(params[:id].to_i) | |||||
| base_version = nil | |||||
| base_snapshot = nil | |||||
| current_snapshot = nil | |||||
| unless force | |||||
| base_version = post.post_versions.find_by!(version_no: base_version_no) | |||||
| base_snapshot = post_snapshot_from_version(base_version) | |||||
| current_snapshot = post_snapshot_from_record(post) | |||||
| end | |||||
| incoming_snapshot = post_incoming_snapshot(title:, | |||||
| original_created_from:, | |||||
| original_created_before:, | |||||
| tag_names:, | |||||
| parent_post_ids:) | |||||
| snapshot_to_apply = | |||||
| if force || post.version_no == base_version_no || current_snapshot == base_snapshot | |||||
| incoming_snapshot | |||||
| else | |||||
| changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) | |||||
| conflicts = changes.select { |change| change[:conflict] } | |||||
| sync_parent_posts!(post, parent_post_ids) | |||||
| if merge && conflicts.empty? | |||||
| merge_post_snapshots(base_snapshot, current_snapshot, incoming_snapshot) | |||||
| else | |||||
| conflict_json = post_conflict_json(post:, | |||||
| base_version_no:, | |||||
| base_snapshot:, | |||||
| current_snapshot:, | |||||
| incoming_snapshot:, | |||||
| changes:, | |||||
| conflicts:) | |||||
| raise ActiveRecord::Rollback | |||||
| end | |||||
| end | |||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||||
| apply_post_snapshot!(post, snapshot_to_apply) | |||||
| end | end | ||||
| return render json: conflict_json, status: :conflict if conflict_json | |||||
| post.reload | post.reload | ||||
| json = post.as_json | |||||
| json = PostRepr.base(post, current_user) | |||||
| json['tags'] = build_tag_tree_for(post.tags) | json['tags'] = build_tag_tree_for(post.tags) | ||||
| render json:, status: :ok | render json:, status: :ok | ||||
| rescue Tag::NicoTagNormalisationError | rescue Tag::NicoTagNormalisationError | ||||
| @@ -225,7 +260,7 @@ class PostsController < ApplicationController | |||||
| pts = pts.where(post_id: id) if id.present? | pts = pts.where(post_id: id) if id.present? | ||||
| pts = pts.where(tag_id:) if tag_id.present? | pts = pts.where(tag_id:) if tag_id.present? | ||||
| pts = pts.includes(:post, :created_user, :deleted_user, | pts = pts.includes(:post, :created_user, :deleted_user, | ||||
| tag: [:materials, { tag_name: :wiki_page }]) | |||||
| tag: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||||
| events = [] | events = [] | ||||
| pts.each do |pt| | pts.each do |pt| | ||||
| @@ -404,4 +439,205 @@ class PostsController < ApplicationController | |||||
| PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) | PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) | ||||
| end | end | ||||
| end | end | ||||
| def parse_base_version_no | |||||
| version_no = Integer(params[:base_version_no], exception: false) | |||||
| if version_no&.positive? | |||||
| version_no | |||||
| else | |||||
| nil | |||||
| end | |||||
| end | |||||
| def post_snapshot_from_version version | |||||
| { title: version.title, | |||||
| original_created_from: snapshot_time(version.original_created_from), | |||||
| original_created_before: snapshot_time(version.original_created_before), | |||||
| tag_names: editable_tag_names_from_version(version), | |||||
| parent_post_ids: snapshot_parent_post_ids_from_version(version) } | |||||
| end | |||||
| def editable_tag_names_from_version version | |||||
| version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort | |||||
| end | |||||
| def post_snapshot_from_record post | |||||
| { title: post.title, | |||||
| original_created_from: snapshot_time(post.original_created_from), | |||||
| original_created_before: snapshot_time(post.original_created_before), | |||||
| tag_names: editable_tag_names_from_post(post), | |||||
| parent_post_ids: post.parent_posts.order(:id).pluck(:id) } | |||||
| end | |||||
| def editable_tag_names_from_post post | |||||
| post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||||
| end | |||||
| def post_incoming_snapshot title:, original_created_from:, original_created_before:, | |||||
| tag_names:, parent_post_ids: | |||||
| { title:, | |||||
| original_created_from: snapshot_time(original_created_from), | |||||
| original_created_before: snapshot_time(original_created_before), | |||||
| tag_names: incoming_tag_names_for_snapshot(tag_names), | |||||
| parent_post_ids: parent_post_ids.sort } | |||||
| end | |||||
| def snapshot_parent_post_ids_from_version version | |||||
| if version.respond_to?(:parent_post_ids) | |||||
| version.parent_post_ids.to_s.split.map { |id| id.to_i }.sort | |||||
| elsif version.respond_to?(:parent_id) && version.parent_id | |||||
| [version.parent_id] | |||||
| else | |||||
| [] | |||||
| end | |||||
| end | |||||
| def snapshot_time value | |||||
| return nil if value.blank? | |||||
| value = Time.zone.parse(value.to_s) if value in String | |||||
| value&.in_time_zone&.iso8601(6) | |||||
| rescue ArgumentError, TypeError | |||||
| value.to_s | |||||
| end | |||||
| def incoming_tag_names_for_snapshot raw_tag_names | |||||
| tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false) | |||||
| Tag.expand_parent_tags(tags).map(&:name).uniq.sort | |||||
| end | |||||
| def post_conflict_json post:, base_version_no:, base_snapshot:, | |||||
| current_snapshot:, incoming_snapshot:, changes:, conflicts: | |||||
| { error: 'conflict', | |||||
| message: '競合が発生しました.', | |||||
| post_id: post.id, | |||||
| base_version_no:, | |||||
| current_version_no: post.version_no, | |||||
| base: base_snapshot, | |||||
| current: current_snapshot, | |||||
| mine: incoming_snapshot, | |||||
| changes:, | |||||
| conflicts:, | |||||
| mergeable: conflicts.empty? } | |||||
| end | |||||
| def post_snapshot_changes base_snapshot, current_snapshot, incoming_snapshot | |||||
| [scalar_snapshot_change(:title, 'タイトル', | |||||
| base_snapshot, current_snapshot, incoming_snapshot), | |||||
| scalar_snapshot_change(:original_created_from, 'オリジナルの作成日時(以降)', | |||||
| base_snapshot, current_snapshot, incoming_snapshot), | |||||
| scalar_snapshot_change(:original_created_before, 'オリジナルの作成日時(より前)', | |||||
| base_snapshot, current_snapshot, incoming_snapshot), | |||||
| set_snapshot_change(:tag_names, 'タグ', | |||||
| base_snapshot, current_snapshot, incoming_snapshot), | |||||
| set_snapshot_change(:parent_post_ids, '親投稿', | |||||
| base_snapshot, current_snapshot, incoming_snapshot)].compact | |||||
| end | |||||
| def scalar_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot | |||||
| base = base_snapshot[field] | |||||
| current = current_snapshot[field] | |||||
| mine = incoming_snapshot[field] | |||||
| return nil if current == base && mine == base | |||||
| { field:, label:, base:, current:, mine:, | |||||
| changed_by_current: current != base, | |||||
| changed_by_me: mine != base, | |||||
| conflict: scalar_snapshot_conflict?(base, current, mine) } | |||||
| end | |||||
| def scalar_snapshot_conflict? base, current, mine | |||||
| current != base && mine != base && current != mine | |||||
| end | |||||
| def set_snapshot_change field, label, base_snapshot, current_snapshot, incoming_snapshot | |||||
| base = base_snapshot[field].to_a | |||||
| current = current_snapshot[field].to_a | |||||
| mine = incoming_snapshot[field].to_a | |||||
| added_by_current = current - base | |||||
| removed_by_current = base - current | |||||
| added_by_me = mine - base | |||||
| removed_by_me = base - mine | |||||
| if (added_by_current.empty? && | |||||
| removed_by_current.empty? && | |||||
| added_by_me.empty? && | |||||
| removed_by_me.empty?) | |||||
| return nil | |||||
| end | |||||
| { field:, label:, base:, current:, mine:, added_by_current:, removed_by_current:, | |||||
| added_by_me:, removed_by_me:, | |||||
| changed_by_current: added_by_current.present? || removed_by_current.present?, | |||||
| changed_by_me: added_by_me.present? || removed_by_me.present?, | |||||
| conflict: set_snapshot_conflict?(added_by_current:, | |||||
| removed_by_current:, | |||||
| added_by_me:, | |||||
| removed_by_me:) } | |||||
| end | |||||
| def set_snapshot_conflict? added_by_current:, removed_by_current:, | |||||
| added_by_me:, removed_by_me: | |||||
| (added_by_current & removed_by_me).present? || (removed_by_current & added_by_me).present? | |||||
| end | |||||
| def apply_post_snapshot! post, snapshot | |||||
| PostVersionRecorder.ensure_snapshot!(post, created_by_user: current_user) | |||||
| post.update!(title: snapshot[:title], | |||||
| original_created_from: snapshot[:original_created_from], | |||||
| original_created_before: snapshot[:original_created_before]) | |||||
| editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) | |||||
| TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) | |||||
| readonly_tags = post.tags.nico.to_a | |||||
| tags = readonly_tags + editable_tags | |||||
| tags = Tag.expand_parent_tags(tags) | |||||
| sync_post_tags!(post, tags) | |||||
| sync_parent_posts!(post, snapshot[:parent_post_ids]) | |||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | |||||
| end | |||||
| def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot | |||||
| [:title, :original_created_from, :original_created_before].map { | |||||
| [_1, merge_scalar_snapshot_value(base_snapshot[_1], | |||||
| current_snapshot[_1], | |||||
| incoming_snapshot[_1])] | |||||
| }.to_h.merge([:tag_names, :parent_post_ids].map { | |||||
| [_1, merge_set_snapshot_value(base_snapshot[_1], | |||||
| current_snapshot[_1], | |||||
| incoming_snapshot[_1])] | |||||
| }.to_h) | |||||
| end | |||||
| def merge_scalar_snapshot_value base, current, mine | |||||
| return mine if current == base | |||||
| return current if mine == base || current == mine | |||||
| raise ArgumentError, '競合してゐる項目はマージできません.' | |||||
| end | |||||
| def merge_set_snapshot_value base, current, mine | |||||
| base = base.to_a | |||||
| current = current.to_a | |||||
| mine = mine.to_a | |||||
| added_by_current = current - base | |||||
| removed_by_current = base - current | |||||
| added_by_me = mine - base | |||||
| removed_by_me = base - mine | |||||
| merged = base + added_by_current + added_by_me | |||||
| merged -= removed_by_current | |||||
| merged -= removed_by_me | |||||
| merged.uniq.sort | |||||
| end | |||||
| end | end | ||||
| @@ -28,6 +28,8 @@ class Post < ApplicationRecord | |||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| attribute :version_no, :integer, default: 1 | |||||
| before_validation :normalise_url | before_validation :normalise_url | ||||
| validates :url, presence: true, uniqueness: true | validates :url, presence: true, uniqueness: true | ||||
| @@ -40,6 +40,8 @@ class Tag < ApplicationRecord | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| delegate :wiki_page, to: :tag_name | delegate :wiki_page, to: :tag_name | ||||
| attribute :version_no, :integer, default: 1 | |||||
| delegate :name, to: :tag_name, allow_nil: true | delegate :name, to: :tag_name, allow_nil: true | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord | |||||
| has_many :wiki_versions | has_many :wiki_versions | ||||
| attribute :version_no, :integer, default: 1 | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| validates :body, presence: true | validates :body, presence: true | ||||
| @@ -16,19 +16,20 @@ class VersionRecorder | |||||
| @record = record_class.unscoped.lock.find(@record.id) | @record = record_class.unscoped.lock.find(@record.id) | ||||
| latest = latest_version | latest = latest_version | ||||
| if !(latest) && @event_type != 'create' | |||||
| raise "#{ version_class.name } first event must be create" | |||||
| end | |||||
| validate_version_sequence!(latest) | |||||
| if @event_type == 'create' && latest | |||||
| raise "#{ version_class.name } create event already exists" | |||||
| attrs = snapshot_attributes | |||||
| if @event_type == 'update' && latest && same_snapshot?(latest, attrs) | |||||
| return latest | |||||
| end | end | ||||
| attrs = snapshot_attributes | |||||
| version = version_class.create!( | |||||
| base_attributes(latest).merge(record_key => @record).merge(attrs)) | |||||
| return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs) | |||||
| update_record_version_no!(version.version_no) | |||||
| version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs)) | |||||
| version | |||||
| end | end | ||||
| end | end | ||||
| @@ -45,7 +46,31 @@ class VersionRecorder | |||||
| created_by_user: @created_by_user } | created_by_user: @created_by_user } | ||||
| end | end | ||||
| def same_snapshot?(version, attrs) = attrs.all? { |k, v| version.public_send(k) == v } | |||||
| def update_record_version_no! version_no | |||||
| @record.update_columns(version_no:) | |||||
| @record.version_no = version_no | |||||
| end | |||||
| def validate_version_sequence! latest | |||||
| if !(latest) && @event_type != 'create' | |||||
| raise "#{ version_class.name } first event must be create" | |||||
| end | |||||
| if @event_type == 'create' && latest | |||||
| raise "#{ version_class.name } create event already exists" | |||||
| end | |||||
| return unless latest | |||||
| if @record.version_no != latest.version_no | |||||
| raise ("#{ record_class.name }##{ @record.id } version_no is #{ @record.version_no }, " + | |||||
| "but latest #{ version_class.name } version_no is #{ latest.version_no }") | |||||
| end | |||||
| end | |||||
| def same_snapshot? version, attrs | |||||
| attrs.all? { |k, v| version.public_send(k) == v } | |||||
| end | |||||
| def validate_event_type! | def validate_event_type! | ||||
| return if EVENT_TYPES.include?(@event_type) | return if EVENT_TYPES.include?(@event_type) | ||||
| @@ -0,0 +1,27 @@ | |||||
| class AddVersionNoToPosts < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| add_column :posts, :version_no, :integer | |||||
| execute <<~SQL | |||||
| UPDATE | |||||
| posts | |||||
| SET | |||||
| version_no = ( | |||||
| SELECT | |||||
| MAX(version_no) | |||||
| FROM | |||||
| post_versions | |||||
| WHERE | |||||
| post_id = posts.id) | |||||
| SQL | |||||
| change_column_null :posts, :version_no, false | |||||
| add_check_constraint :posts, 'version_no > 0', name: 'chk_posts_version_no_positive' | |||||
| end | |||||
| def down | |||||
| remove_check_constraint :posts, name: 'chk_posts_version_no_positive' | |||||
| remove_column :posts, :version_no | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,37 @@ | |||||
| class AddVersionNoToTags < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| add_column :tags, :version_no, :integer | |||||
| execute <<~SQL | |||||
| UPDATE | |||||
| tags | |||||
| SET | |||||
| version_no = ( | |||||
| CASE category | |||||
| WHEN 'nico' THEN | |||||
| (SELECT | |||||
| MAX(version_no) | |||||
| FROM | |||||
| nico_tag_versions | |||||
| WHERE | |||||
| tag_id = tags.id) | |||||
| ELSE | |||||
| (SELECT | |||||
| MAX(version_no) | |||||
| FROM | |||||
| tag_versions | |||||
| WHERE | |||||
| tag_id = tags.id) | |||||
| END) | |||||
| SQL | |||||
| change_column_null :tags, :version_no, false | |||||
| add_check_constraint :tags, 'version_no > 0', name: 'chk_tags_version_no_positive' | |||||
| end | |||||
| def down | |||||
| remove_check_constraint :tags, name: 'chk_tags_version_no_positive' | |||||
| remove_column :tags, :version_no | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,27 @@ | |||||
| class AddVersionNoToWikiPages < ActiveRecord::Migration[8.0] | |||||
| def up | |||||
| add_column :wiki_pages, :version_no, :integer | |||||
| execute <<~SQL | |||||
| UPDATE | |||||
| wiki_pages | |||||
| SET | |||||
| version_no = ( | |||||
| SELECT | |||||
| MAX(version_no) | |||||
| FROM | |||||
| wiki_versions | |||||
| WHERE | |||||
| wiki_page_id = wiki_pages.id) | |||||
| SQL | |||||
| change_column_null :wiki_pages, :version_no, false | |||||
| add_check_constraint :wiki_pages, 'version_no > 0', name: 'chk_wiki_pages_version_no_positive' | |||||
| end | |||||
| def down | |||||
| remove_check_constraint :wiki_pages, name: 'chk_wiki_pages_version_no_positive' | |||||
| remove_column :wiki_pages, :version_no | |||||
| end | |||||
| end | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||
| ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do | |||||
| ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do | |||||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -186,8 +186,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do | |||||
| t.datetime "original_created_from" | t.datetime "original_created_from" | ||||
| t.datetime "original_created_before" | t.datetime "original_created_before" | ||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.integer "version_no", null: false | |||||
| t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | ||||
| t.index ["url"], name: "index_posts_on_url", unique: true | t.index ["url"], name: "index_posts_on_url", unique: true | ||||
| t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" | |||||
| end | end | ||||
| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| @@ -262,8 +264,10 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do | |||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.integer "post_count", default: 0, null: false | t.integer "post_count", default: 0, null: false | ||||
| t.datetime "discarded_at" | t.datetime "discarded_at" | ||||
| t.integer "version_no", null: false | |||||
| t.index ["discarded_at"], name: "index_tags_on_discarded_at" | t.index ["discarded_at"], name: "index_tags_on_discarded_at" | ||||
| t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true | t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true | ||||
| t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" | |||||
| end | end | ||||
| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| @@ -369,10 +373,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_01_153900) do | |||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.datetime "discarded_at" | t.datetime "discarded_at" | ||||
| t.integer "next_asset_no", default: 1, null: false | t.integer "next_asset_no", default: 1, null: false | ||||
| t.integer "version_no", null: false | |||||
| t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | ||||
| t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" | t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" | ||||
| t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true | t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true | ||||
| t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" | t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" | ||||
| t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" | |||||
| end | end | ||||
| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| @@ -10,6 +10,10 @@ RSpec.describe 'Posts API', type: :request 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) | ||||
| end | end | ||||
| def create_nico_tag!(name) | |||||
| Tag.find_or_create_by_tag_name!(name, category: :nico) | |||||
| end | |||||
| def dummy_upload | def dummy_upload | ||||
| # 中身は何でもいい(加工処理はスタブしてる) | # 中身は何でもいい(加工処理はスタブしてる) | ||||
| 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') | ||||
| @@ -23,7 +27,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| Post.create!(title:, url:) | Post.create!(title:, url:) | ||||
| end | end | ||||
| def create_post_version_for! post | |||||
| def create_post_version_for!(post) | |||||
| version = | |||||
| PostVersion.create!( | PostVersion.create!( | ||||
| post:, | post:, | ||||
| version_no: 1, | version_no: 1, | ||||
| @@ -36,8 +41,20 @@ RSpec.describe 'Posts API', type: :request do | |||||
| original_created_from: post.original_created_from, | original_created_from: post.original_created_from, | ||||
| original_created_before: post.original_created_before, | original_created_before: post.original_created_before, | ||||
| created_at: post.created_at, | created_at: post.created_at, | ||||
| created_by_user: post.uploaded_user | |||||
| ) | |||||
| created_by_user: post.uploaded_user) | |||||
| post.update_columns(version_no: version.version_no) if post.has_attribute?(:version_no) | |||||
| post.version_no = version.version_no if post.respond_to?(:version_no=) | |||||
| version | |||||
| end | |||||
| def post_update_params(post, params = { }) | |||||
| base_version = | |||||
| post.post_versions.order(version_no: :desc).first || | |||||
| create_post_version_for!(post.reload) | |||||
| post_write_params({ base_version_no: base_version.version_no }.merge(params)) | |||||
| end | end | ||||
| let!(:tag_name) { TagName.create!(name: 'spec_tag') } | let!(:tag_name) { TagName.create!(name: 'spec_tag') } | ||||
| @@ -806,24 +823,26 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it '401 when not logged in' do | it '401 when not logged in' do | ||||
| sign_out | sign_out | ||||
| put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, title: 'updated', tags: 'spec_tag') | |||||
| expect(response).to have_http_status(:unauthorized) | expect(response).to have_http_status(:unauthorized) | ||||
| end | end | ||||
| it '403 when not member' do | it '403 when not member' do | ||||
| sign_in_as(create(:user, role: 'guest')) | sign_in_as(create(:user, role: 'guest')) | ||||
| put "/posts/#{post_record.id}", params: post_write_params(title: 'updated', tags: 'spec_tag') | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, title: 'updated', tags: 'spec_tag') | |||||
| expect(response).to have_http_status(:forbidden) | expect(response).to have_http_status(:forbidden) | ||||
| end | end | ||||
| it '200 and updates title + resync tags when member' do | it '200 and updates title + resync tags when member' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| # 追加で別タグも作って、更新時に入れ替わることを見る | |||||
| tn2 = TagName.create!(name: 'spec_tag_2') | tn2 = TagName.create!(name: 'spec_tag_2') | ||||
| Tag.create!(tag_name: tn2, category: :general) | Tag.create!(tag_name: tn2, category: :general) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag_2') | tags: 'spec_tag_2') | ||||
| @@ -831,7 +850,6 @@ RSpec.describe 'Posts API', type: :request do | |||||
| expect(json).to have_key('tags') | expect(json).to have_key('tags') | ||||
| expect(json['tags']).to be_an(Array) | expect(json['tags']).to be_an(Array) | ||||
| # show と同様、update 後レスポンスもツリー形式 | |||||
| names = json['tags'].map { |n| n['name'] } | names = json['tags'].map { |n| n['name'] } | ||||
| expect(names).to include('spec_tag_2') | expect(names).to include('spec_tag_2') | ||||
| end | end | ||||
| @@ -846,10 +864,10 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'return 400' do | it 'return 400' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'nico:nico_tag' | |||||
| ) | |||||
| tags: 'nico:nico_tag') | |||||
| expect(response).to have_http_status(:bad_request), response.body | expect(response).to have_http_status(:bad_request), response.body | ||||
| end | end | ||||
| @@ -887,11 +905,11 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'replaces parent posts' do | it 'replaces parent posts' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" | |||||
| ) | |||||
| parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}") | |||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| @@ -908,7 +926,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'clears parent posts when parent_post_ids is blank' do | it 'clears parent posts when parent_post_ids is blank' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: '' | parent_post_ids: '' | ||||
| @@ -922,7 +941,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| sign_in_as(member) | sign_in_as(member) | ||||
| create_post_version_for!(post_record.reload) | create_post_version_for!(post_record.reload) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" | parent_post_ids: "#{new_parent_post_1.id} #{new_parent_post_2.id}" | ||||
| @@ -943,7 +963,10 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'returns 422' do | it 'returns 422' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| put "/posts/#{post_record.id}", params: { | put "/posts/#{post_record.id}", params: { | ||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag' } | tags: 'spec_tag' } | ||||
| @@ -966,7 +989,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| parent_post: | parent_post: | ||||
| ) | ) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: 'abc' | parent_post_ids: 'abc' | ||||
| @@ -991,7 +1015,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| parent_post: | parent_post: | ||||
| ) | ) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: '999999999' | parent_post_ids: '999999999' | ||||
| @@ -1006,7 +1031,8 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'returns 422 and does not create self implication' do | it 'returns 422 and does not create self implication' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| put "/posts/#{post_record.id}", params: post_update_params( | |||||
| post_record, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| parent_post_ids: post_record.id.to_s | parent_post_ids: post_record.id.to_s | ||||
| @@ -1020,6 +1046,221 @@ RSpec.describe 'Posts API', type: :request do | |||||
| )).to be(false) | )).to be(false) | ||||
| end | end | ||||
| end | end | ||||
| context 'with optimistic locking' do | |||||
| let!(:no_deerjikist_tag) { Tag.no_deerjikist } | |||||
| before do | |||||
| PostTag.create!(post: post_record, tag: no_deerjikist_tag) | |||||
| end | |||||
| it '400 when base_version_no is missing without force' do | |||||
| sign_in_as(member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| title: 'updated title', | |||||
| tags: 'spec_tag') | |||||
| expect(response).to have_http_status(:bad_request) | |||||
| end | |||||
| it '400 when force and merge are both true' do | |||||
| sign_in_as(member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| title: 'updated title', | |||||
| tags: 'spec_tag', | |||||
| force: '1', | |||||
| merge: '1') | |||||
| expect(response).to have_http_status(:bad_request) | |||||
| end | |||||
| it '409 when scalar fields are changed both by current and incoming updates' do | |||||
| sign_in_as(member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| post_record.update!(title: 'updated by other user') | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated by me', | |||||
| tags: "spec_tag #{Tag.no_deerjikist.name}") | |||||
| expect(response).to have_http_status(:conflict) | |||||
| expect(json.fetch('error')).to eq('conflict') | |||||
| expect(json.fetch('base_version_no')).to eq(base_version.version_no) | |||||
| expect(json.fetch('current_version_no')).to eq(2) | |||||
| expect(json.fetch('mergeable')).to be(false) | |||||
| conflict_fields = json.fetch('conflicts').map { |change| change.fetch('field') } | |||||
| expect(conflict_fields).to include('title') | |||||
| expect(post_record.reload.title).to eq('updated by other user') | |||||
| end | |||||
| it 'returns 409 with mergeable true when stale tag changes do not conflict but merge is not requested' do | |||||
| sign_in_as(member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| current_tag = Tag.find_or_create_by_tag_name!('current_added_tag', category: :general) | |||||
| PostTag.create!(post: post_record, tag: current_tag, created_user: member) | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: post_record.title, | |||||
| tags: "spec_tag #{Tag.no_deerjikist.name} incoming_added_tag") | |||||
| expect(response).to have_http_status(:conflict) | |||||
| expect(json.fetch('mergeable')).to be(true) | |||||
| tag_change = json.fetch('changes').find { |change| change.fetch('field') == 'tag_names' } | |||||
| expect(tag_change).to be_present | |||||
| expect(tag_change.fetch('conflict')).to be(false) | |||||
| expect(tag_change.fetch('added_by_current')).to include('current_added_tag') | |||||
| expect(tag_change.fetch('added_by_me')).to include('incoming_added_tag') | |||||
| end | |||||
| it 'merges non-conflicting stale tag changes when merge is true' do | |||||
| sign_in_as(member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| current_tag = Tag.find_or_create_by_tag_name!('current_merge_tag', category: :general) | |||||
| PostTag.create!(post: post_record, tag: current_tag, created_user: member) | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: post_record.title, | |||||
| tags: "spec_tag #{Tag.no_deerjikist.name} incoming_merge_tag", | |||||
| merge: '1') | |||||
| expect(response).to have_http_status(:ok) | |||||
| names = post_record.reload.tags.map(&:name) | |||||
| expect(names).to include('spec_tag') | |||||
| expect(names).to include(Tag.no_deerjikist.name) | |||||
| expect(names).to include('current_merge_tag') | |||||
| expect(names).to include('incoming_merge_tag') | |||||
| end | |||||
| it 'does not conflict when only nico tags changed after the base version' do | |||||
| sign_in_as(member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| nico_tag = create_nico_tag!('nico:optimistic_lock_nico') | |||||
| PostTag.create!(post: post_record, tag: nico_tag, created_user: member) | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| expect(post_record.reload.version_no).to eq(2) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: post_record.title, | |||||
| tags: "spec_tag #{ Tag.no_deerjikist.name }") | |||||
| expect(response).to have_http_status(:ok) | |||||
| names = post_record.reload.tags.map(&:name) | |||||
| expect(names).to include('spec_tag') | |||||
| expect(names).to include(Tag.no_deerjikist.name) | |||||
| expect(names).to include(nico_tag.name) | |||||
| end | |||||
| it 'keeps nico tags even when they are not included in PUT tags' do | |||||
| sign_in_as(member) | |||||
| nico_tag = create_nico_tag!('nico:readonly_update_nico') | |||||
| PostTag.create!(post: post_record, tag: nico_tag, created_user: member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated title', | |||||
| tags: "spec_tag #{ Tag.no_deerjikist.name }") | |||||
| expect(response).to have_http_status(:ok) | |||||
| names = post_record.reload.tags.map(&:name) | |||||
| expect(names).to include('spec_tag') | |||||
| expect(names).to include(Tag.no_deerjikist.name) | |||||
| expect(names).to include(nico_tag.name) | |||||
| end | |||||
| it 'allows non-nico tags linked from nico tags to be removed by normal post update' do | |||||
| sign_in_as(member) | |||||
| nico_tag = create_nico_tag!('nico:relation_source') | |||||
| linked_tag = Tag.find_or_create_by_tag_name!('relation_linked_tag', category: :general) | |||||
| NicoTagRelation.create!(nico_tag:, tag: linked_tag) | |||||
| PostTag.create!(post: post_record, tag: nico_tag, created_user: member) | |||||
| PostTag.create!(post: post_record, tag: linked_tag, created_user: member) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| base_version_no: base_version.version_no, | |||||
| title: post_record.title, | |||||
| tags: "spec_tag #{ Tag.no_deerjikist.name }") | |||||
| expect(response).to have_http_status(:ok) | |||||
| names = post_record.reload.tags.map(&:name) | |||||
| expect(names).to include(nico_tag.name) | |||||
| expect(names).to include('spec_tag') | |||||
| expect(names).to include(Tag.no_deerjikist.name) | |||||
| expect(names).not_to include(linked_tag.name) | |||||
| end | |||||
| it 'force-updates stale posts without base_version_no' do | |||||
| sign_in_as(member) | |||||
| create_post_version_for!(post_record.reload) | |||||
| post_record.update!(title: 'updated by other user') | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| put "/posts/#{post_record.id}", params: post_write_params( | |||||
| title: 'forced title', | |||||
| tags: "spec_tag #{Tag.no_deerjikist.name}", | |||||
| force: '1') | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(post_record.reload.title).to eq('forced title') | |||||
| end | |||||
| end | |||||
| end | end | ||||
| describe 'GET /posts/random' do | describe 'GET /posts/random' do | ||||
| @@ -1434,13 +1675,14 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'creates next version on PUT /posts/:id when snapshot changes' do | it 'creates next version on PUT /posts/:id when snapshot changes' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| create_post_version_for!(post_record) | |||||
| base_version = create_post_version_for!(post_record) | |||||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | tag_name2 = TagName.create!(name: 'spec_tag_2') | ||||
| Tag.create!(tag_name: tag_name2, category: :general) | Tag.create!(tag_name: tag_name2, category: :general) | ||||
| expect do | expect do | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | put "/posts/#{post_record.id}", params: post_write_params( | ||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag_2') | tags: 'spec_tag_2') | ||||
| end.to change(PostVersion, :count).by(1) | end.to change(PostVersion, :count).by(1) | ||||
| @@ -1459,13 +1701,15 @@ RSpec.describe 'Posts API', type: :request do | |||||
| sign_in_as(member) | sign_in_as(member) | ||||
| PostTag.create!(post: post_record, tag: Tag.no_deerjikist) | PostTag.create!(post: post_record, tag: Tag.no_deerjikist) | ||||
| create_post_version_for!(post_record.reload) | |||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| expect { | expect { | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | put "/posts/#{post_record.id}", params: post_write_params( | ||||
| base_version_no: base_version.version_no, | |||||
| title: post_record.title, | title: post_record.title, | ||||
| tags: 'spec_tag') | tags: 'spec_tag') | ||||
| }.not_to change(PostVersion, :count) | }.not_to change(PostVersion, :count) | ||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| version = post_record.reload.post_versions.order(:version_no).last | version = post_record.reload.post_versions.order(:version_no).last | ||||
| @@ -1490,10 +1734,11 @@ RSpec.describe 'Posts API', type: :request do | |||||
| it 'does not create a version when PUT /posts/:id is invalid' do | it 'does not create a version when PUT /posts/:id is invalid' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| create_post_version_for!(post_record) | |||||
| base_version = create_post_version_for!(post_record) | |||||
| expect do | expect do | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | put "/posts/#{post_record.id}", params: post_write_params( | ||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag', | tags: 'spec_tag', | ||||
| original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, | original_created_from: Time.zone.local(2020, 1, 2, 0, 0, 0).iso8601, | ||||
| @@ -1507,46 +1752,22 @@ RSpec.describe 'Posts API', type: :request do | |||||
| describe 'tag versioning from post write actions' do | describe 'tag versioning from post write actions' do | ||||
| let(:member) { create(:user, :member) } | let(:member) { create(:user, :member) } | ||||
| it 'creates tag snapshot for normalised tags on POST /posts' do | |||||
| sign_in_as(member) | |||||
| expect { | |||||
| 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) | |||||
| version = tag.reload.tag_versions.order(:version_no).last | |||||
| expect(version.version_no).to eq(1) | |||||
| expect(version.event_type).to eq('create') | |||||
| expect(version.name).to eq('spec_tag') | |||||
| expect(version.category).to eq('general') | |||||
| expect(version.created_by_user_id).to eq(member.id) | |||||
| end | |||||
| it 'creates tag snapshot for normalised tags on PUT /posts/:id' do | it 'creates tag snapshot for normalised tags on PUT /posts/:id' do | ||||
| sign_in_as(member) | sign_in_as(member) | ||||
| base_version = create_post_version_for!(post_record.reload) | |||||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | tag_name2 = TagName.create!(name: 'spec_tag_2') | ||||
| tag2 = Tag.create!(tag_name: tag_name2, category: :general) | tag2 = Tag.create!(tag_name: tag_name2, category: :general) | ||||
| expect { | expect { | ||||
| put "/posts/#{post_record.id}", params: post_write_params( | put "/posts/#{post_record.id}", params: post_write_params( | ||||
| base_version_no: base_version.version_no, | |||||
| title: 'updated title', | title: 'updated title', | ||||
| tags: 'spec_tag_2') | tags: 'spec_tag_2') | ||||
| }.to change { tag2.reload.tag_versions.count }.by(1) | }.to change { tag2.reload.tag_versions.count }.by(1) | ||||
| expect(response).to have_http_status(:ok) | |||||
| version = tag2.reload.tag_versions.order(:version_no).last | |||||
| expect(version.version_no).to eq(1) | |||||
| expect(version.event_type).to eq('create') | |||||
| expect(version.name).to eq('spec_tag_2') | |||||
| expect(version.created_by_user_id).to eq(member.id) | |||||
| expect(response).to have_http_status(:ok), response.body | |||||
| end | end | ||||
| end | end | ||||
| end | end | ||||
| @@ -26,6 +26,7 @@ RSpec.describe 'TagVersions API', type: :request do | |||||
| created_by_user:, | created_by_user:, | ||||
| created_at: | created_at: | ||||
| ) | ) | ||||
| version = | |||||
| TagVersion.create!( | TagVersion.create!( | ||||
| tag: tag, | tag: tag, | ||||
| version_no: version_no, | version_no: version_no, | ||||
| @@ -35,8 +36,12 @@ RSpec.describe 'TagVersions API', type: :request do | |||||
| aliases: Array(aliases).join(' '), | aliases: Array(aliases).join(' '), | ||||
| parent_tag_ids: Array(parent_tags).map(&:id).join(' '), | parent_tag_ids: Array(parent_tags).map(&:id).join(' '), | ||||
| created_by_user: created_by_user, | created_by_user: created_by_user, | ||||
| created_at: created_at | |||||
| ) | |||||
| created_at: created_at) | |||||
| tag.update_columns(version_no: version_no) if tag.has_attribute?(:version_no) | |||||
| tag.version_no = version_no if tag.respond_to?(:version_no=) | |||||
| version | |||||
| end | end | ||||
| let!(:v1) do | let!(:v1) do | ||||
| @@ -0,0 +1,85 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe VersionRecorder do | |||||
| let(:member) { create(:user, :member) } | |||||
| let(:post_record) do | |||||
| Post.create!( | |||||
| title: 'version recorder post', | |||||
| url: 'https://example.com/version-recorder-post') | |||||
| end | |||||
| it 'updates record version_no when creating the first version' do | |||||
| version = | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record, | |||||
| event_type: :create, | |||||
| created_by_user: member) | |||||
| expect(version.version_no).to eq(1) | |||||
| expect(post_record.reload.version_no).to eq(1) | |||||
| end | |||||
| it 'updates record version_no when creating the next version' do | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record, | |||||
| event_type: :create, | |||||
| created_by_user: member) | |||||
| post_record.update!(title: 'updated version recorder post') | |||||
| version = | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| expect(version.version_no).to eq(2) | |||||
| expect(post_record.reload.version_no).to eq(2) | |||||
| end | |||||
| it 'does not create a new version or advance version_no when snapshot is unchanged' do | |||||
| first = | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record, | |||||
| event_type: :create, | |||||
| created_by_user: member) | |||||
| expect { | |||||
| version = | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| expect(version).to eq(first) | |||||
| }.not_to change(PostVersion, :count) | |||||
| expect(post_record.reload.version_no).to eq(1) | |||||
| end | |||||
| it 'raises when record version_no is older than the latest version' do | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record, | |||||
| event_type: :create, | |||||
| created_by_user: member) | |||||
| post_record.update!(title: 'updated once') | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| post_record.update_columns(version_no: 1) | |||||
| post_record.update!(title: 'updated with stale version_no') | |||||
| expect { | |||||
| PostVersionRecorder.record!( | |||||
| post: post_record.reload, | |||||
| event_type: :update, | |||||
| created_by_user: member) | |||||
| }.to raise_error(RuntimeError, /version_no/) | |||||
| end | |||||
| end | |||||
| @@ -8,6 +8,7 @@ import { BrowserRouter, | |||||
| 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 { Toaster } from '@/components/ui/toaster' | import { Toaster } from '@/components/ui/toaster' | ||||
| import { apiPost, isApiError } from '@/lib/api' | import { apiPost, isApiError } from '@/lib/api' | ||||
| import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' | import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' | ||||
| @@ -138,7 +139,9 @@ export default (() => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <RouteBlockerOverlay/> | <RouteBlockerOverlay/> | ||||
| <BrowserRouter> | <BrowserRouter> | ||||
| <DialogueProvider> | |||||
| <LayoutGroup> | <LayoutGroup> | ||||
| <motion.div | <motion.div | ||||
| layout="position" | layout="position" | ||||
| @@ -148,7 +151,9 @@ export default (() => { | |||||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | <RouteTransitionWrapper user={user} setUser={setUser}/> | ||||
| </motion.div> | </motion.div> | ||||
| </LayoutGroup> | </LayoutGroup> | ||||
| <Toaster/> | <Toaster/> | ||||
| </DialogueProvider> | |||||
| </BrowserRouter> | </BrowserRouter> | ||||
| </>) | </>) | ||||
| }) satisfies FC | }) satisfies FC | ||||
| @@ -3,11 +3,12 @@ import { useEffect, useState } from 'react' | |||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { apiPut } from '@/lib/api' | |||||
| import { updatePost } from '@/lib/posts' | |||||
| import type { FC } from 'react' | |||||
| import type { FC, FormEvent } from 'react' | |||||
| import type { Post, Tag } from '@/types' | import type { Post, Tag } from '@/types' | ||||
| @@ -32,6 +33,7 @@ type Props = { post: Post | |||||
| export default (({ post, onSave }: Props) => { | export default (({ post, onSave }: Props) => { | ||||
| const [disabled, setDisabled] = useState (false) | |||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | const [originalCreatedBefore, setOriginalCreatedBefore] = | ||||
| useState<string | null> (post.originalCreatedBefore) | useState<string | null> (post.originalCreatedBefore) | ||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | const [originalCreatedFrom, setOriginalCreatedFrom] = | ||||
| @@ -41,16 +43,14 @@ export default (({ post, onSave }: Props) => { | |||||
| const [tags, setTags] = useState<string> ('') | const [tags, setTags] = useState<string> ('') | ||||
| const [title, setTitle] = useState (post.title) | const [title, setTitle] = useState (post.title) | ||||
| const handleSubmit = async () => { | |||||
| const dialogue = useDialogue () | |||||
| const update = async (...args: Parameters<typeof updatePost>) => { | |||||
| try | 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' } }) | |||||
| const data = await updatePost (...args) | |||||
| onSave ({ ...post, | onSave ({ ...post, | ||||
| versionNo: data.versionNo, | |||||
| title: data.title, | title: data.title, | ||||
| tags: data.tags, | tags: data.tags, | ||||
| parentPosts: data.parentPosts, | parentPosts: data.parentPosts, | ||||
| @@ -60,9 +60,58 @@ export default (({ post, onSave }: Props) => { | |||||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | originalCreatedBefore: data.originalCreatedBefore } as Post) | ||||
| toast ({ description: '更新しました.' }) | toast ({ description: '更新しました.' }) | ||||
| } | } | ||||
| catch | |||||
| catch (e) | |||||
| { | |||||
| const response = (e as any)?.response | |||||
| if (response?.status !== 409) | |||||
| { | { | ||||
| toast ({ description: '更新はできなかったよ……' }) | toast ({ description: '更新はできなかったよ……' }) | ||||
| return | |||||
| } | |||||
| const action = await dialogue.choice ({ | |||||
| title: '競合が発生しました.', | |||||
| description: ( | |||||
| <div> | |||||
| <p>ほかの耕作員が先に更新してゐます.</p> | |||||
| <p>現在の変更をどう扱ひますか?</p> | |||||
| </div>), | |||||
| choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []), | |||||
| { value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) | |||||
| if (action === 'merge') | |||||
| { | |||||
| // TODO: 差分 UI | |||||
| await update ({ id: post.id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { baseVersionNo: post.versionNo, merge: true }) | |||||
| return | |||||
| } | |||||
| if (action === 'overwrite') | |||||
| { | |||||
| await update ({ id: post.id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { baseVersionNo: post.versionNo, force: true }) | |||||
| return | |||||
| } | |||||
| } | |||||
| } | |||||
| const handleSubmit = async (e: FormEvent) => { | |||||
| e.preventDefault () | |||||
| setDisabled (true) | |||||
| try | |||||
| { | |||||
| await update ({ id: post.id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { baseVersionNo: post.versionNo }) | |||||
| } | |||||
| finally | |||||
| { | |||||
| setDisabled (false) | |||||
| } | } | ||||
| } | } | ||||
| @@ -71,11 +120,13 @@ export default (({ post, onSave }: Props) => { | |||||
| }, [post]) | }, [post]) | ||||
| return ( | return ( | ||||
| <div className="max-w-xl pt-2 space-y-4"> | |||||
| <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> | |||||
| {/* タイトル */} | {/* タイトル */} | ||||
| <div> | <div> | ||||
| <Label>タイトル</Label> | <Label>タイトル</Label> | ||||
| <input type="text" | |||||
| <input | |||||
| type="text" | |||||
| disabled={disabled} | |||||
| className="w-full border rounded p-2" | className="w-full border rounded p-2" | ||||
| value={title ?? ''} | value={title ?? ''} | ||||
| onChange={ev => setTitle (ev.target.value)}/> | onChange={ev => setTitle (ev.target.value)}/> | ||||
| @@ -86,25 +137,31 @@ export default (({ post, onSave }: Props) => { | |||||
| <Label>親投稿</Label> | <Label>親投稿</Label> | ||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| disabled={disabled} | |||||
| value={parentPostIds} | value={parentPostIds} | ||||
| onChange={e => setParentPostIds (e.target.value)} | onChange={e => setParentPostIds (e.target.value)} | ||||
| className="w-full border p-2 rounded"/> | className="w-full border p-2 rounded"/> | ||||
| </div> | </div> | ||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||||
| <PostFormTagsArea | |||||
| disabled={disabled} | |||||
| tags={tags} | |||||
| setTags={setTags}/> | |||||
| {/* オリジナルの作成日時 */} | {/* オリジナルの作成日時 */} | ||||
| <PostOriginalCreatedTimeField | <PostOriginalCreatedTimeField | ||||
| disabled={disabled} | |||||
| originalCreatedFrom={originalCreatedFrom} | originalCreatedFrom={originalCreatedFrom} | ||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | setOriginalCreatedFrom={setOriginalCreatedFrom} | ||||
| originalCreatedBefore={originalCreatedBefore} | originalCreatedBefore={originalCreatedBefore} | ||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | setOriginalCreatedBefore={setOriginalCreatedBefore}/> | ||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| <Button | |||||
| type="submit" | |||||
| disabled={disabled}> | |||||
| 更新 | 更新 | ||||
| </Button> | </Button> | ||||
| </div>) | |||||
| </form>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -3,6 +3,7 @@ import YoutubeEmbed from 'react-youtube' | |||||
| import NicoViewer from '@/components/NicoViewer' | import NicoViewer from '@/components/NicoViewer' | ||||
| import TwitterEmbed from '@/components/TwitterEmbed' | import TwitterEmbed from '@/components/TwitterEmbed' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||||
| import type { FC, RefObject } from 'react' | import type { FC, RefObject } from 'react' | ||||
| @@ -16,6 +17,8 @@ type Props = { | |||||
| export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | ||||
| const dialogue = useDialogue () | |||||
| const url = new URL (post.url) | const url = new URL (post.url) | ||||
| switch (url.hostname.split ('.').slice (-2).join ('.')) | switch (url.hostname.split ('.').slice (-2).join ('.')) | ||||
| @@ -82,12 +85,17 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||||
| height={360}/>) | height={360}/>) | ||||
| : ( | : ( | ||||
| <div> | <div> | ||||
| <a href="#" onClick={e => { | |||||
| <a href="#" onClick={async e => { | |||||
| e.preventDefault () | e.preventDefault () | ||||
| setFramed (confirm ('未確認の外部ページを表示します。\n' | |||||
| + '悪意のあるスクリプトが実行される可能性があります。\n' | |||||
| + '表示しますか?')) | |||||
| return | |||||
| setFramed (await dialogue.confirm ({ | |||||
| title: '未確認の外部ページを表示します。', | |||||
| description: ( | |||||
| <div> | |||||
| <p>悪意のあるスクリプトが実行される可能性があります。</p> | |||||
| <p>表示しますか?</p> | |||||
| </div>), | |||||
| confirmText: '表示' })) | |||||
| }}> | }}> | ||||
| 外部ページを表示 | 外部ページを表示 | ||||
| </a> | </a> | ||||
| @@ -7,7 +7,7 @@ import Label from '@/components/common/Label' | |||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| import type { FC, SyntheticEvent } from 'react' | |||||
| import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| @@ -31,12 +31,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) = | |||||
| `${ value.slice (0, start) }${ text }${ value.slice (end) }` | `${ value.slice (0, start) }${ text }${ value.slice (end) }` | ||||
| type Props = { | |||||
| type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { | |||||
| tags: string | tags: string | ||||
| setTags: (tags: string) => void } | setTags: (tags: string) => void } | ||||
| export default (({ tags, setTags }: Props) => { | |||||
| export default (({ tags, setTags, ...rest }: Props) => { | |||||
| const ref = useRef<HTMLTextAreaElement> (null) | const ref = useRef<HTMLTextAreaElement> (null) | ||||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | ||||
| @@ -76,6 +76,7 @@ export default (({ tags, setTags }: Props) => { | |||||
| <div className="relative w-full"> | <div className="relative w-full"> | ||||
| <Label>タグ</Label> | <Label>タグ</Label> | ||||
| <TextArea | <TextArea | ||||
| {...rest} | |||||
| ref={ref} | ref={ref} | ||||
| value={tags} | value={tags} | ||||
| onChange={ev => setTags (ev.target.value)} | onChange={ev => setTags (ev.target.value)} | ||||
| @@ -42,7 +42,7 @@ export default (({ posts, onClick }: Props) => { | |||||
| layoutId={layoutId} | layoutId={layoutId} | ||||
| className={cn ('w-full h-full overflow-hidden rounded-xl shadow', | className={cn ('w-full h-full overflow-hidden rounded-xl shadow', | ||||
| 'transform-gpu will-change-transform', | 'transform-gpu will-change-transform', | ||||
| (post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500', | |||||
| (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', | |||||
| (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} | (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} | ||||
| whileHover={{ scale: 1.02 }} | whileHover={{ scale: 1.02 }} | ||||
| onLayoutAnimationStart={() => { | onLayoutAnimationStart={() => { | ||||
| @@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| type Props = { | type Props = { | ||||
| disabled?: boolean | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| setOriginalCreatedFrom: (x: string | null) => void | setOriginalCreatedFrom: (x: string | null) => void | ||||
| originalCreatedBefore: string | null | originalCreatedBefore: string | null | ||||
| setOriginalCreatedBefore: (x: string | null) => void } | setOriginalCreatedBefore: (x: string | null) => void } | ||||
| export default (({ originalCreatedFrom, | |||||
| export default (({ disabled, | |||||
| originalCreatedFrom, | |||||
| setOriginalCreatedFrom, | setOriginalCreatedFrom, | ||||
| originalCreatedBefore, | originalCreatedBefore, | ||||
| setOriginalCreatedBefore }: Props) => ( | setOriginalCreatedBefore }: Props) => ( | ||||
| @@ -21,6 +23,7 @@ export default (({ originalCreatedFrom, | |||||
| <div className="w-80"> | <div className="w-80"> | ||||
| <DateTimeField | <DateTimeField | ||||
| className="mr-2" | className="mr-2" | ||||
| disabled={disabled ?? false} | |||||
| value={originalCreatedFrom ?? undefined} | value={originalCreatedFrom ?? undefined} | ||||
| onChange={setOriginalCreatedFrom} | onChange={setOriginalCreatedFrom} | ||||
| onBlur={ev => { | onBlur={ev => { | ||||
| @@ -40,6 +43,7 @@ export default (({ originalCreatedFrom, | |||||
| <div> | <div> | ||||
| <Button | <Button | ||||
| className="bg-gray-600 text-white rounded" | className="bg-gray-600 text-white rounded" | ||||
| disabled={disabled} | |||||
| onClick={() => { | onClick={() => { | ||||
| setOriginalCreatedFrom (null) | setOriginalCreatedFrom (null) | ||||
| }}> | }}> | ||||
| @@ -51,6 +55,7 @@ export default (({ originalCreatedFrom, | |||||
| <div className="w-80"> | <div className="w-80"> | ||||
| <DateTimeField | <DateTimeField | ||||
| className="mr-2" | className="mr-2" | ||||
| disabled={disabled} | |||||
| value={originalCreatedBefore ?? undefined} | value={originalCreatedBefore ?? undefined} | ||||
| onChange={setOriginalCreatedBefore}/> | onChange={setOriginalCreatedBefore}/> | ||||
| より前 | より前 | ||||
| @@ -58,6 +63,7 @@ export default (({ originalCreatedFrom, | |||||
| <div> | <div> | ||||
| <Button | <Button | ||||
| className="bg-gray-600 text-white rounded" | className="bg-gray-600 text-white rounded" | ||||
| disabled={disabled} | |||||
| onClick={() => { | onClick={() => { | ||||
| setOriginalCreatedBefore (null) | setOriginalCreatedBefore (null) | ||||
| }}> | }}> | ||||
| @@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| { name: '検索', to: '/posts/search' }, | { name: '検索', to: '/posts/search' }, | ||||
| { name: '追加', to: '/posts/new' }, | { name: '追加', to: '/posts/new' }, | ||||
| { name: '履歴', to: '/posts/changes' }, | |||||
| { name: '全体履歴', to: '/posts/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'マスタ', to: '/tags' }, | { name: 'マスタ', to: '/tags' }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: '履歴', to: '/tags/changes' }, | |||||
| { name: '全体履歴', to: '/tags/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | ||||
| { component: <Separator/>, visible: tagFlg }, | { component: <Separator/>, visible: tagFlg }, | ||||
| { name: `広場 (${ postCount || 0 })`, | { name: `広場 (${ postCount || 0 })`, | ||||
| @@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: '一覧', to: '/materials' }, | { name: '一覧', to: '/materials' }, | ||||
| { name: '検索', to: '/materials/search', visible: false }, | { name: '検索', to: '/materials/search', visible: false }, | ||||
| { name: '追加', to: '/materials/new' }, | { name: '追加', to: '/materials/new' }, | ||||
| { name: '履歴', to: '/materials/changes', visible: false }, | |||||
| { name: '全体履歴', to: '/materials/changes', visible: false }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, | ||||
| { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ | { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ | ||||
| { name: <>第 1 会場</>, to: '/theatres/1' }, | { name: <>第 1 会場</>, to: '/theatres/1' }, | ||||
| @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { FC, FocusEvent } from 'react' | |||||
| import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react' | |||||
| const pad = (n: number): string => n.toString ().padStart (2, '0') | const pad = (n: number): string => n.toString ().padStart (2, '0') | ||||
| @@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => { | |||||
| } | } | ||||
| type Props = { | |||||
| type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { | |||||
| value?: string | value?: string | ||||
| onChange?: (isoUTC: string | null) => void | onChange?: (isoUTC: string | null) => void | ||||
| className?: string | className?: string | ||||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | ||||
| export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| const [local, setLocal] = useState ('') | const [local, setLocal] = useState ('') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| return ( | return ( | ||||
| <input | <input | ||||
| {...rest} | |||||
| className={cn ('border rounded p-2', className)} | className={cn ('border rounded p-2', className)} | ||||
| type="datetime-local" | type="datetime-local" | ||||
| value={local} | value={local} | ||||
| @@ -0,0 +1,187 @@ | |||||
| import { createContext, useCallback, useContext, useMemo, useState } from 'react' | |||||
| import { Button } from '@/components/ui/button' | |||||
| import { Dialog, | |||||
| DialogContent, | |||||
| DialogDescription, | |||||
| DialogFooter, | |||||
| DialogHeader, | |||||
| DialogTitle } from '@/components/ui/dialog' | |||||
| import type { FC, ReactNode } from 'react' | |||||
| type DialogueVariant = 'default' | 'danger' | |||||
| type ConfirmOptions = { title: string | |||||
| description?: ReactNode | |||||
| confirmText?: string | |||||
| cancelText?: string | |||||
| variant?: DialogueVariant } | |||||
| type AlertOptions = { title: string | |||||
| description?: ReactNode | |||||
| okText?: string } | |||||
| type Choice<T extends string> = { value: T | |||||
| label: string | |||||
| variant?: DialogueVariant } | |||||
| type ChoiceOptions<T extends string> = { title: string | |||||
| description?: ReactNode | |||||
| choices: Choice<T>[] | |||||
| cancelText?: string } | |||||
| type DialogueRequest = | |||||
| | { id: number | |||||
| kind: 'confirm' | |||||
| options: ConfirmOptions | |||||
| resolve: (value: boolean) => void } | |||||
| | { id: number | |||||
| kind: 'alert' | |||||
| options: AlertOptions | |||||
| resolve: () => void } | |||||
| | { id: number | |||||
| kind: 'choice' | |||||
| options: ChoiceOptions<string> | |||||
| resolve: (value: string | null) => void } | |||||
| type DialogueAPI = | |||||
| { confirm: (options: ConfirmOptions) => Promise<boolean> | |||||
| alert: (options: AlertOptions) => Promise<void> | |||||
| choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> } | |||||
| const DialogueContext = createContext<DialogueAPI | null> (null) | |||||
| let nextDialogueId = 1 | |||||
| type Props = { children: ReactNode } | |||||
| export default (({ children }: Props) => { | |||||
| const [queue, setQueue] = useState<DialogueRequest[]> ([]) | |||||
| const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => { | |||||
| const id = nextDialogueId | |||||
| ++nextDialogueId | |||||
| setQueue (q => [...q, { ...request, id } as DialogueRequest]) | |||||
| }, []) | |||||
| const closeActive = useCallback ((result?: unknown) => { | |||||
| setQueue (q => { | |||||
| const [active, ...rest] = q | |||||
| if (!(active)) | |||||
| return rest | |||||
| switch (active.kind) | |||||
| { | |||||
| case 'confirm': | |||||
| active.resolve (Boolean (result)) | |||||
| break | |||||
| case 'alert': | |||||
| active.resolve () | |||||
| break | |||||
| case 'choice': | |||||
| active.resolve ((result ?? null) as string | null) | |||||
| break | |||||
| } | |||||
| return rest | |||||
| }) | |||||
| }, []) | |||||
| const api = useMemo<DialogueAPI> (() => ({ | |||||
| confirm: options => new Promise<boolean> (resolve => { | |||||
| push ({ kind: 'confirm', options, resolve }) | |||||
| }), | |||||
| alert: options => new Promise<void> (resolve => { | |||||
| push ({ kind: 'alert', options, resolve }) | |||||
| }), | |||||
| choice: options => new Promise (resolve => { | |||||
| push ({ kind: 'choice', | |||||
| options: options as ChoiceOptions<string>, | |||||
| resolve: resolve as (value: string | null) => void }) | |||||
| }) }), [push]) | |||||
| const active = queue[0] | |||||
| return ( | |||||
| <DialogueContext.Provider value={api}> | |||||
| {children} | |||||
| <Dialog | |||||
| open={Boolean (active)} | |||||
| onOpenChange={open => { | |||||
| if (!(open)) | |||||
| closeActive (active?.kind !== 'confirm' && null) | |||||
| }}> | |||||
| {active && ( | |||||
| <DialogContent className="px-6 pb-6 pt-7"> | |||||
| <DialogHeader className="pl-8"> | |||||
| <DialogTitle>{active.options.title}</DialogTitle> | |||||
| {active.options.description && ( | |||||
| <DialogDescription asChild> | |||||
| <div>{active.options.description}</div> | |||||
| </DialogDescription>)} | |||||
| </DialogHeader> | |||||
| <DialogFooter> | |||||
| {active.kind === 'confirm' && ( | |||||
| <> | |||||
| <Button | |||||
| variant="outline" | |||||
| onClick={() => closeActive (false)}> | |||||
| {active.options.cancelText ?? '取消'} | |||||
| </Button> | |||||
| <Button | |||||
| variant={(active.options.variant === 'danger') | |||||
| ? 'destructive' | |||||
| : 'default'} | |||||
| onClick={() => closeActive (true)}> | |||||
| {active.options.confirmText ?? '確定'} | |||||
| </Button> | |||||
| </>)} | |||||
| {active.kind === 'alert' && ( | |||||
| <Button onClick={() => closeActive ()}> | |||||
| {active.options.okText ?? '確定'} | |||||
| </Button>)} | |||||
| {active.kind === 'choice' && ( | |||||
| <> | |||||
| <Button | |||||
| variant="outline" | |||||
| onClick={() => closeActive (null)}> | |||||
| {active.options.cancelText ?? '取消'} | |||||
| </Button> | |||||
| {active.options.choices.map (choice => ( | |||||
| <Button | |||||
| key={choice.value} | |||||
| variant={(choice.variant === 'danger') | |||||
| ? 'destructive' | |||||
| : 'default'} | |||||
| onClick={() => closeActive (choice.value)}> | |||||
| {choice.label} | |||||
| </Button>))} | |||||
| </>)} | |||||
| </DialogFooter> | |||||
| </DialogContent>)} | |||||
| </Dialog> | |||||
| </DialogueContext.Provider>) | |||||
| }) satisfies FC<Props> | |||||
| export const useDialogue = () => { | |||||
| const dialogue = useContext (DialogueContext) | |||||
| if (!(dialogue)) | |||||
| throw new Error ('useDialogue must be used inside DialogueProvider') | |||||
| return dialogue | |||||
| } | |||||
| @@ -5,33 +5,46 @@ import { cva, type VariantProps } from "class-variance-authority" | |||||
| import { cn } from "@/lib/utils" | import { cn } from "@/lib/utils" | ||||
| const buttonVariants = cva ( | const buttonVariants = cva ( | ||||
| "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | |||||
| [ | |||||
| 'inline-flex items-center justify-center gap-2 whitespace-nowrap', | |||||
| 'rounded-md text-sm font-medium transition-colors', | |||||
| 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400', | |||||
| 'disabled:pointer-events-none disabled:opacity-50', | |||||
| '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', | |||||
| ].join (' '), | |||||
| { | { | ||||
| variants: { | variants: { | ||||
| variant: { | variant: { | ||||
| default: "bg-primary text-primary-foreground hover:bg-primary/90", | |||||
| default: | |||||
| 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300', | |||||
| destructive: | destructive: | ||||
| "bg-destructive text-destructive-foreground hover:bg-destructive/90", | |||||
| 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600', | |||||
| outline: | outline: | ||||
| "border border-input bg-background hover:bg-accent hover:text-accent-foreground", | |||||
| 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800', | |||||
| secondary: | secondary: | ||||
| "bg-secondary text-secondary-foreground hover:bg-secondary/80", | |||||
| ghost: "hover:bg-accent hover:text-accent-foreground", | |||||
| link: "text-primary underline-offset-4 hover:underline", | |||||
| 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700', | |||||
| ghost: | |||||
| 'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800', | |||||
| link: | |||||
| 'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300', | |||||
| }, | }, | ||||
| size: { | size: { | ||||
| default: "h-10 px-4 py-2", | |||||
| sm: "h-9 rounded-md px-3", | |||||
| lg: "h-11 rounded-md px-8", | |||||
| icon: "h-10 w-10", | |||||
| default: 'h-10 px-4 py-2', | |||||
| sm: 'h-9 rounded-md px-3', | |||||
| lg: 'h-11 rounded-md px-8', | |||||
| icon: 'h-10 w-10', | |||||
| }, | }, | ||||
| }, | }, | ||||
| defaultVariants: { | defaultVariants: { | ||||
| variant: "default", | |||||
| size: "default", | |||||
| variant: 'default', | |||||
| size: 'default', | |||||
| }, | }, | ||||
| } | |||||
| ) | |||||
| }) | |||||
| export interface ButtonProps | export interface ButtonProps | ||||
| extends React.ButtonHTMLAttributes<HTMLButtonElement>, | extends React.ButtonHTMLAttributes<HTMLButtonElement>, | ||||
| @@ -38,24 +38,28 @@ const DialogContent = React.forwardRef< | |||||
| <DialogPrimitive.Content | <DialogPrimitive.Content | ||||
| ref={ref} | ref={ref} | ||||
| className={cn ( | className={cn ( | ||||
| 'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg', | |||||
| 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg', | |||||
| 'translate-x-[-50%] translate-y-[-50%]', | 'translate-x-[-50%] translate-y-[-50%]', | ||||
| 'gap-4 border bg-gray-300/80 dark:bg-gray-700/80', | |||||
| 'p-6 shadow-lg duration-200', | |||||
| 'gap-5 rounded-2xl border border-border', | |||||
| 'bg-background p-6 text-foreground shadow-2xl', | |||||
| 'duration-200', | |||||
| 'data-[state=open]:animate-in data-[state=closed]:animate-out', | 'data-[state=open]:animate-in data-[state=closed]:animate-out', | ||||
| 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | ||||
| 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', | 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', | ||||
| 'data-[state=closed]:slide-out-to-left-1/2', | |||||
| 'data-[state=closed]:slide-out-to-top-[48%]', | |||||
| 'data-[state=open]:slide-in-from-left-1/2', | |||||
| 'data-[state=open]:slide-in-from-top-[48%] rounded-lg', | |||||
| className)} | className)} | ||||
| {...props} | {...props} | ||||
| > | > | ||||
| {children} | {children} | ||||
| <DialogPrimitive.Close className="absolute right-4 top-4 bg-red-500 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> | |||||
| <X className="h-3 w-3" /> | |||||
| <span className="sr-only">Close</span> | |||||
| <DialogPrimitive.Close | |||||
| className={cn ( | |||||
| 'absolute left-4 top-4 rounded-full p-1', | |||||
| 'text-slate-500 transition-colors', | |||||
| 'hover:bg-slate-200 hover:text-slate-900', | |||||
| 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50', | |||||
| 'focus:outline-none focus:ring-2 focus:ring-slate-400')}> | |||||
| <X className="h-4 w-4"/> | |||||
| <span className="sr-only">閉ぢる</span> | |||||
| </DialogPrimitive.Close> | </DialogPrimitive.Close> | ||||
| </DialogPrimitive.Content> | </DialogPrimitive.Content> | ||||
| </DialogPortal> | </DialogPortal> | ||||
| @@ -1,8 +1,11 @@ | |||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogDescription, | |||||
| DialogHeader, | |||||
| DialogTitle } from '@/components/ui/dialog' | DialogTitle } from '@/components/ui/dialog' | ||||
| import { Input } from '@/components/ui/input' | import { Input } from '@/components/ui/input' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| @@ -16,10 +19,16 @@ type Props = { visible: boolean | |||||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | export default ({ visible, onVisibleChange, setUser }: Props) => { | ||||
| const dialogue = useDialogue () | |||||
| const [inputCode, setInputCode] = useState ('') | const [inputCode, setInputCode] = useState ('') | ||||
| const handleTransfer = async () => { | const handleTransfer = async () => { | ||||
| if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.'))) | |||||
| if (!(await dialogue.confirm ({ | |||||
| title: '引継ぎを行ってもよろしいですか?', | |||||
| description: '現在のアカウントからはログアウトされます.', | |||||
| confirmText: '引継ぐ', | |||||
| variant: 'danger' }))) | |||||
| return | return | ||||
| try | try | ||||
| @@ -44,14 +53,18 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| return ( | return ( | ||||
| <Dialog open={visible} onOpenChange={onVisibleChange}> | <Dialog open={visible} onOpenChange={onVisibleChange}> | ||||
| <DialogContent> | |||||
| <DialogContent className="px-6 pp-6 pt-7"> | |||||
| <DialogHeader className="pl-8"> | |||||
| <DialogTitle>ほかのブラウザから引継ぐ</DialogTitle> | <DialogTitle>ほかのブラウザから引継ぐ</DialogTitle> | ||||
| <DialogDescription asChild> | |||||
| <div className="flex gap-2"> | <div className="flex gap-2"> | ||||
| <Input placeholder="引継ぎコードを入力" | <Input placeholder="引継ぎコードを入力" | ||||
| value={inputCode} | value={inputCode} | ||||
| onChange={ev => setInputCode (ev.target.value)}/> | onChange={ev => setInputCode (ev.target.value)}/> | ||||
| <Button onClick={handleTransfer}>引継ぐ</Button> | <Button onClick={handleTransfer}>引継ぐ</Button> | ||||
| </div> | </div> | ||||
| </DialogDescription> | |||||
| </DialogHeader> | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </Dialog>) | ||||
| } | } | ||||
| @@ -1,6 +1,10 @@ | |||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogDescription, | |||||
| DialogFooter, | |||||
| DialogHeader, | |||||
| DialogTitle } from '@/components/ui/dialog' | DialogTitle } from '@/components/ui/dialog' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { apiPost } from '@/lib/api' | import { apiPost } from '@/lib/api' | ||||
| @@ -14,11 +18,20 @@ type Props = { visible: boolean | |||||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | export default ({ visible, onVisibleChange, user, setUser }: Props) => { | ||||
| const dialogue = useDialogue () | |||||
| const handleChange = async () => { | const handleChange = async () => { | ||||
| if (!(user)) | if (!(user)) | ||||
| return | return | ||||
| if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | |||||
| if (!(await dialogue.confirm ({ | |||||
| title: '引継ぎコードを再発行しますか?', | |||||
| description: ( | |||||
| <div> | |||||
| <p>再発行するとほかのブラウザからはログアウトされます.</p> | |||||
| </div>), | |||||
| confirmText: '再発行', | |||||
| variant: 'danger' }))) | |||||
| return | return | ||||
| const data = await apiPost<{ code: string }> ('/users/code/renew', { }, | const data = await apiPost<{ code: string }> ('/users/code/renew', { }, | ||||
| @@ -33,21 +46,26 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| return ( | return ( | ||||
| <Dialog open={visible} onOpenChange={onVisibleChange}> | <Dialog open={visible} onOpenChange={onVisibleChange}> | ||||
| <DialogContent> | |||||
| <DialogContent className="px-6 pb-6 pt-7"> | |||||
| <DialogHeader className="pl-8"> | |||||
| <DialogTitle>引継ぎコード</DialogTitle> | <DialogTitle>引継ぎコード</DialogTitle> | ||||
| <DialogDescription asChild> | |||||
| <div> | <div> | ||||
| <p>あなたの引継ぎコードはこちらです:</p> | <p>あなたの引継ぎコードはこちらです:</p> | ||||
| <div className="m-2">{user?.inheritanceCode}</div> | <div className="m-2">{user?.inheritanceCode}</div> | ||||
| <p className="mt-1 text-sm text-red-500"> | |||||
| <p className="mt-1 text-sm text-destructive"> | |||||
| このコードはほかの人には教えないでください! | このコードはほかの人には教えないでください! | ||||
| </p> | </p> | ||||
| <div className="my-4"> | |||||
| <Button onClick={handleChange} | |||||
| className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400"> | |||||
| </div> | |||||
| </DialogDescription> | |||||
| </DialogHeader> | |||||
| <DialogFooter> | |||||
| <Button onClick={handleChange} variant="destructive"> | |||||
| 引継ぎコード再発行 | 引継ぎコード再発行 | ||||
| </Button> | </Button> | ||||
| </div> | |||||
| </div> | |||||
| </DialogFooter> | |||||
| </DialogContent> | </DialogContent> | ||||
| </Dialog>) | </Dialog>) | ||||
| } | } | ||||
| @@ -6,6 +6,56 @@ | |||||
| @layer base | @layer base | ||||
| { | { | ||||
| :root | |||||
| { | |||||
| --background: 0 0% 100%; | |||||
| --foreground: 222.2 84% 4.9%; | |||||
| --primary: 222.2 47.4% 11.2%; | |||||
| --primary-foreground: 210 40% 98%; | |||||
| --secondary: 210 40% 96.1%; | |||||
| --secondary-foreground: 222.2 47.4% 11.2%; | |||||
| --destructive: 0 72.2% 50.6%; | |||||
| --destructive-foreground: 210 40% 98%; | |||||
| --muted: 210 40% 96.1%; | |||||
| --muted-foreground: 215.4 16.3% 46.9%; | |||||
| --accent: 210 40% 96.1%; | |||||
| --accent-foreground: 222.2 47.4% 11.2%; | |||||
| --border: 214.3 31.8% 91.4%; | |||||
| --input: 214.3 31.8% 91.4%; | |||||
| --ring: 222.2 84% 4.9%; | |||||
| } | |||||
| .dark | |||||
| { | |||||
| --background: 222.2 84% 4.9%; | |||||
| --foreground: 210 40% 98%; | |||||
| --primary: 210 40% 98%; | |||||
| --primary-foreground: 222.2 47.4% 11.2%; | |||||
| --secondary: 217.2 32.6% 17.5%; | |||||
| --secondary-foreground: 210 40% 98%; | |||||
| --destructive: 0 62.8% 45%; | |||||
| --destructive-foreground: 210 40% 98%; | |||||
| --muted: 217.2 32.6% 17.5%; | |||||
| --muted-foreground: 215 20.2% 65.1%; | |||||
| --accent: 217.2 32.6% 17.5%; | |||||
| --accent-foreground: 210 40% 98%; | |||||
| --border: 217.2 32.6% 17.5%; | |||||
| --input: 217.2 32.6% 17.5%; | |||||
| --ring: 212.7 26.8% 83.9%; | |||||
| } | |||||
| body | body | ||||
| { | { | ||||
| @apply overflow-x-clip; | @apply overflow-x-clip; | ||||
| @@ -54,34 +104,6 @@ body | |||||
| min-height: 100dvh; | min-height: 100dvh; | ||||
| } | } | ||||
| h1 | |||||
| { | |||||
| font-size: 3.2em; | |||||
| line-height: 1.1; | |||||
| } | |||||
| button | |||||
| { | |||||
| border-radius: 8px; | |||||
| border: 1px solid transparent; | |||||
| padding: 0.6em 1.2em; | |||||
| font-size: 1em; | |||||
| font-weight: 500; | |||||
| font-family: inherit; | |||||
| background-color: #1a1a1a; | |||||
| cursor: pointer; | |||||
| transition: border-color 0.25s; | |||||
| } | |||||
| button:hover | |||||
| { | |||||
| border-color: #646cff; | |||||
| } | |||||
| button:focus, | |||||
| button:focus-visible | |||||
| { | |||||
| outline: 4px auto -webkit-focus-ring-color; | |||||
| } | |||||
| @media (prefers-color-scheme: light) | @media (prefers-color-scheme: light) | ||||
| { | { | ||||
| :root | :root | ||||
| @@ -1,4 +1,4 @@ | |||||
| import { apiDelete, apiGet, apiPost } from '@/lib/api' | |||||
| import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' | |||||
| import type { FetchPostsParams, Post, PostVersion } from '@/types' | import type { FetchPostsParams, Post, PostVersion } from '@/types' | ||||
| @@ -42,6 +42,30 @@ export const fetchPostChanges = async ( | |||||
| page, limit } }) | page, limit } }) | ||||
| export const updatePost = async ( | |||||
| post: { id: number | |||||
| title: string | null | |||||
| tags: string | |||||
| parentPostIds: string | |||||
| originalCreatedFrom: string | null | |||||
| originalCreatedBefore: string | null }, | |||||
| { baseVersionNo, force, merge }: { | |||||
| baseVersionNo?: number | |||||
| force?: boolean | |||||
| merge?: boolean } | |||||
| ) => | |||||
| await apiPut<Post> ( | |||||
| `/posts/${ post.id }`, | |||||
| { title: post.title, | |||||
| tags: post.tags, | |||||
| parent_post_ids: post.parentPostIds, | |||||
| original_created_from: post.originalCreatedFrom, | |||||
| original_created_before: post.originalCreatedBefore }, | |||||
| { params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }), | |||||
| force: force ? '1' : '0', | |||||
| merge: merge ? '1' : '0' } }) | |||||
| export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | ||||
| await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | ||||
| } | } | ||||
| @@ -8,16 +8,18 @@ import TagLink from '@/components/TagLink' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import Pagination from '@/components/common/Pagination' | import Pagination from '@/components/common/Pagination' | ||||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { apiPut } from '@/lib/api' | |||||
| import { fetchPostChanges } from '@/lib/posts' | |||||
| import { fetchPostChanges, updatePost } from '@/lib/posts' | |||||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | import { postsKeys, tagsKeys } from '@/lib/queryKeys' | ||||
| import { fetchTag } from '@/lib/tags' | import { fetchTag } from '@/lib/tags' | ||||
| import { cn, dateString, originalCreatedAtString } from '@/lib/utils' | import { cn, dateString, originalCreatedAtString } from '@/lib/utils' | ||||
| import type { FC } from 'react' | |||||
| import type { FC, MouseEvent } from 'react' | |||||
| import type { PostVersion } from '@/types' | |||||
| const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | ||||
| @@ -34,6 +36,8 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||||
| export default (() => { | export default (() => { | ||||
| const dialogue = useDialogue () | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| @@ -62,6 +66,48 @@ export default (() => { | |||||
| const qc = useQueryClient () | const qc = useQueryClient () | ||||
| const handleRevert = async (e: MouseEvent<HTMLAnchorElement>, change: PostVersion) => { | |||||
| e.preventDefault () | |||||
| if (!(await dialogue.confirm ({ | |||||
| title: '差戻の確認', | |||||
| description: `『${ change.title.current || change.url.current }』を版 ${ | |||||
| change.versionNo } に差戻します.\nよろしいですか?`, | |||||
| confirmText: '差戻' }))) | |||||
| return | |||||
| try | |||||
| { | |||||
| const id = change.postId | |||||
| const title = change.title.current | |||||
| const tags = | |||||
| change.tags | |||||
| .filter (t => t.type !== 'removed') | |||||
| .map (t => t.name) | |||||
| .filter (t => t.slice (0, 5) !== 'nico:') | |||||
| .join (' ') | |||||
| const parentPostIds = | |||||
| (change.parentPosts ?? []) | |||||
| .filter (p => p.type !== 'removed') | |||||
| .map (p => p.id) | |||||
| .join (' ') | |||||
| const originalCreatedFrom = change.originalCreatedFrom.current | |||||
| const originalCreatedBefore = change.originalCreatedBefore.current | |||||
| await updatePost ({ id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { force: true }) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| toast ({ description: '差戻しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '差戻に失敗……' }) | |||||
| } | |||||
| } | |||||
| useEffect (() => { | useEffect (() => { | ||||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | ||||
| }, [location.search]) | }, [location.search]) | ||||
| @@ -231,46 +277,7 @@ export default (() => { | |||||
| {dateString (change.createdAt)} | {dateString (change.createdAt)} | ||||
| </td> | </td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <a | |||||
| href="#" | |||||
| onClick={async e => { | |||||
| e.preventDefault () | |||||
| if (!(confirm ( | |||||
| `『${ change.title.current | |||||
| || change.url.current }』を版 ${ | |||||
| change.versionNo } に差戻します.\nよろしいですか?`))) | |||||
| return | |||||
| try | |||||
| { | |||||
| await apiPut ( | |||||
| `/posts/${ change.postId }`, | |||||
| { title: change.title.current, | |||||
| tags: change.tags | |||||
| .filter (t => t.type !== 'removed') | |||||
| .map (t => t.name) | |||||
| .filter (t => t.slice (0, 5) !== 'nico:') | |||||
| .join (' '), | |||||
| parent_post_ids: | |||||
| (change.parentPosts ?? []) | |||||
| .filter (p => p.type !== 'removed') | |||||
| .map (p => p.id) | |||||
| .join (' '), | |||||
| original_created_from: | |||||
| change.originalCreatedFrom.current, | |||||
| original_created_before: | |||||
| change.originalCreatedBefore.current }) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| qc.invalidateQueries ({ queryKey: tagsKeys.root }) | |||||
| toast ({ description: '差戻しました.' }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| toast ({ description: '差戻に失敗……' }) | |||||
| } | |||||
| }}> | |||||
| <a href="#" onClick={async e => await handleRevert (e, change)}> | |||||
| 復元 | 復元 | ||||
| </a> | </a> | ||||
| </td> | </td> | ||||
| @@ -121,6 +121,7 @@ export type Platform = typeof PLATFORMS[number] | |||||
| export type Post = { | export type Post = { | ||||
| id: number | id: number | ||||
| versionNo: number | |||||
| url: string | url: string | ||||
| title: string | null | title: string | null | ||||
| thumbnail: string | null | thumbnail: string | null | ||||
| @@ -146,6 +147,7 @@ export type PostTagChange = { | |||||
| export type PostVersion = { | export type PostVersion = { | ||||
| postId: number | postId: number | ||||
| latestVersionNo: number | |||||
| versionNo: number | versionNo: number | ||||
| eventType: 'create' | 'update' | 'discard' | 'restore' | eventType: 'create' | 'update' | 'discard' | 'restore' | ||||
| title: { current: string | null; prev: string | null } | title: { current: string | null; prev: string | null } | ||||
| @@ -19,7 +19,22 @@ export default { | |||||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | ||||
| colors: { | colors: { | ||||
| red: { 925: '#5f1414', | red: { 925: '#5f1414', | ||||
| 975: '#230505' } }, | |||||
| 975: '#230505' }, | |||||
| border: 'hsl(var(--border))', | |||||
| input: 'hsl(var(--input))', | |||||
| ring: 'hsl(var(--ring))', | |||||
| background: 'hsl(var(--background))', | |||||
| foreground: 'hsl(var(--foreground))', | |||||
| primary: { DEFAULT: 'hsl(var(--primary))', | |||||
| foreground: 'hsl(var(--primary-foreground))' }, | |||||
| secondary: { DEFAULT: 'hsl(var(--secondary))', | |||||
| foreground: 'hsl(var(--secondary-foreground))' }, | |||||
| destructive: { DEFAULT: 'hsl(var(--destructive))', | |||||
| foreground: 'hsl(var(--destructive-foreground))' }, | |||||
| muted: { DEFAULT: 'hsl(var(--muted))', | |||||
| foreground: 'hsl(var(--muted-foreground))' }, | |||||
| accent: { DEFAULT: 'hsl(var(--accent))', | |||||
| foreground: 'hsl(var(--accent-foreground))' } }, | |||||
| keyframes: { | keyframes: { | ||||
| 'rainbow-scroll': { | 'rainbow-scroll': { | ||||
| '0%': { backgroundPosition: '0% 50%' }, | '0%': { backgroundPosition: '0% 50%' }, | ||||