| @@ -44,7 +44,7 @@ class PostsController < ApplicationController | |||
| filtered_posts | |||
| .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") | |||
| .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) | |||
| .preload(tags: [:materials, { tag_name: :wiki_page }]) | |||
| .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||
| .with_attached_thumbnail | |||
| q = q.where('posts.url LIKE ?', "%#{ url }%") if url | |||
| @@ -95,7 +95,7 @@ class PostsController < ApplicationController | |||
| end | |||
| def random | |||
| post = filtered_posts.preload(tags: [:materials, { tag_name: :wiki_page }]) | |||
| post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||
| .order('RAND()') | |||
| .first | |||
| return head :not_found unless post | |||
| @@ -104,7 +104,7 @@ class PostsController < ApplicationController | |||
| end | |||
| def show | |||
| post = Post.includes(tags: [: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 | |||
| render json: PostRepr.base(post, current_user) | |||
| @@ -173,33 +173,68 @@ class PostsController < ApplicationController | |||
| return head :unauthorized unless current_user | |||
| 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 | |||
| tag_names = params[:tags].to_s.split | |||
| original_created_from = params[:original_created_from] | |||
| original_created_before = params[:original_created_before] | |||
| parent_post_ids = parse_parent_post_ids | |||
| post = Post.find(params[:id].to_i) | |||
| post = nil | |||
| conflict_json = nil | |||
| 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 | |||
| return render json: conflict_json, status: :conflict if conflict_json | |||
| post.reload | |||
| json = post.as_json | |||
| json = PostRepr.base(post, current_user) | |||
| json['tags'] = build_tag_tree_for(post.tags) | |||
| render json:, status: :ok | |||
| rescue Tag::NicoTagNormalisationError | |||
| @@ -225,7 +260,7 @@ class PostsController < ApplicationController | |||
| pts = pts.where(post_id: id) if id.present? | |||
| pts = pts.where(tag_id:) if tag_id.present? | |||
| pts = pts.includes(:post, :created_user, :deleted_user, | |||
| tag: [:materials, { tag_name: :wiki_page }]) | |||
| tag: [:deerjikists, :materials, { tag_name: :wiki_page }]) | |||
| events = [] | |||
| pts.each do |pt| | |||
| @@ -404,4 +439,205 @@ class PostsController < ApplicationController | |||
| PostImplication.create_or_find_by!(post_id: post.id, parent_post_id:) | |||
| 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 | |||
| @@ -28,6 +28,8 @@ class Post < ApplicationRecord | |||
| has_one_attached :thumbnail | |||
| attribute :version_no, :integer, default: 1 | |||
| before_validation :normalise_url | |||
| validates :url, presence: true, uniqueness: true | |||
| @@ -40,6 +40,8 @@ class Tag < ApplicationRecord | |||
| belongs_to :tag_name | |||
| delegate :wiki_page, to: :tag_name | |||
| attribute :version_no, :integer, default: 1 | |||
| delegate :name, to: :tag_name, allow_nil: true | |||
| validates :tag_name, presence: true | |||
| @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord | |||
| has_many :wiki_versions | |||
| attribute :version_no, :integer, default: 1 | |||
| belongs_to :tag_name | |||
| validates :tag_name, presence: true | |||
| validates :body, presence: true | |||
| @@ -16,19 +16,20 @@ class VersionRecorder | |||
| @record = record_class.unscoped.lock.find(@record.id) | |||
| latest = latest_version | |||
| if !(latest) && @event_type != 'create' | |||
| raise "#{ version_class.name } first event must be create" | |||
| end | |||
| 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 | |||
| 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 | |||
| @@ -45,7 +46,31 @@ class VersionRecorder | |||
| created_by_user: @created_by_user } | |||
| 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! | |||
| 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. | |||
| 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| | |||
| t.string "name", 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_before" | |||
| 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 ["url"], name: "index_posts_on_url", unique: true | |||
| t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" | |||
| end | |||
| 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.integer "post_count", default: 0, null: false | |||
| t.datetime "discarded_at" | |||
| t.integer "version_no", null: false | |||
| 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.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive" | |||
| end | |||
| 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 "discarded_at" | |||
| 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 ["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 ["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 | |||
| 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) | |||
| end | |||
| def create_nico_tag!(name) | |||
| Tag.find_or_create_by_tag_name!(name, category: :nico) | |||
| end | |||
| def dummy_upload | |||
| # 中身は何でもいい(加工処理はスタブしてる) | |||
| 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:) | |||
| end | |||
| def create_post_version_for! post | |||
| def create_post_version_for!(post) | |||
| version = | |||
| PostVersion.create!( | |||
| post:, | |||
| version_no: 1, | |||
| @@ -36,8 +41,20 @@ RSpec.describe 'Posts API', type: :request do | |||
| original_created_from: post.original_created_from, | |||
| original_created_before: post.original_created_before, | |||
| created_at: post.created_at, | |||
| created_by_user: post.uploaded_user | |||
| ) | |||
| 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 | |||
| 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 | |||
| 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) | |||
| end | |||
| it '403 when not member' do | |||
| 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) | |||
| end | |||
| it '200 and updates title + resync tags when member' do | |||
| sign_in_as(member) | |||
| # 追加で別タグも作って、更新時に入れ替わることを見る | |||
| tn2 = TagName.create!(name: 'spec_tag_2') | |||
| 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', | |||
| tags: 'spec_tag_2') | |||
| @@ -831,7 +850,6 @@ RSpec.describe 'Posts API', type: :request do | |||
| expect(json).to have_key('tags') | |||
| expect(json['tags']).to be_an(Array) | |||
| # show と同様、update 後レスポンスもツリー形式 | |||
| names = json['tags'].map { |n| n['name'] } | |||
| expect(names).to include('spec_tag_2') | |||
| end | |||
| @@ -846,10 +864,10 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'return 400' do | |||
| 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', | |||
| tags: 'nico:nico_tag' | |||
| ) | |||
| tags: 'nico:nico_tag') | |||
| expect(response).to have_http_status(:bad_request), response.body | |||
| end | |||
| @@ -887,11 +905,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'replaces parent posts' do | |||
| 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', | |||
| 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) | |||
| @@ -908,7 +926,8 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'clears parent posts when parent_post_ids is blank' do | |||
| 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', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: '' | |||
| @@ -922,7 +941,8 @@ RSpec.describe 'Posts API', type: :request do | |||
| sign_in_as(member) | |||
| 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', | |||
| tags: 'spec_tag', | |||
| 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 | |||
| sign_in_as(member) | |||
| base_version = create_post_version_for!(post_record.reload) | |||
| put "/posts/#{post_record.id}", params: { | |||
| base_version_no: base_version.version_no, | |||
| title: 'updated title', | |||
| tags: 'spec_tag' } | |||
| @@ -966,7 +989,8 @@ RSpec.describe 'Posts API', type: :request do | |||
| 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', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: 'abc' | |||
| @@ -991,7 +1015,8 @@ RSpec.describe 'Posts API', type: :request do | |||
| 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', | |||
| tags: 'spec_tag', | |||
| 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 | |||
| 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', | |||
| tags: 'spec_tag', | |||
| parent_post_ids: post_record.id.to_s | |||
| @@ -1020,6 +1046,221 @@ RSpec.describe 'Posts API', type: :request do | |||
| )).to be(false) | |||
| 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 | |||
| 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 | |||
| 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.create!(tag_name: tag_name2, category: :general) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| base_version_no: base_version.version_no, | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2') | |||
| end.to change(PostVersion, :count).by(1) | |||
| @@ -1459,13 +1701,15 @@ RSpec.describe 'Posts API', type: :request do | |||
| sign_in_as(member) | |||
| 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 { | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| base_version_no: base_version.version_no, | |||
| title: post_record.title, | |||
| tags: 'spec_tag') | |||
| }.not_to change(PostVersion, :count) | |||
| expect(response).to have_http_status(:ok) | |||
| version = post_record.reload.post_versions.order(:version_no).last | |||
| @@ -1490,10 +1734,11 @@ RSpec.describe 'Posts API', type: :request do | |||
| it 'does not create a version when PUT /posts/:id is invalid' do | |||
| sign_in_as(member) | |||
| create_post_version_for!(post_record) | |||
| base_version = create_post_version_for!(post_record) | |||
| expect do | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| base_version_no: base_version.version_no, | |||
| title: 'updated title', | |||
| tags: 'spec_tag', | |||
| 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 | |||
| 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 | |||
| sign_in_as(member) | |||
| base_version = create_post_version_for!(post_record.reload) | |||
| tag_name2 = TagName.create!(name: 'spec_tag_2') | |||
| tag2 = Tag.create!(tag_name: tag_name2, category: :general) | |||
| expect { | |||
| put "/posts/#{post_record.id}", params: post_write_params( | |||
| base_version_no: base_version.version_no, | |||
| title: 'updated title', | |||
| tags: 'spec_tag_2') | |||
| }.to change { tag2.reload.tag_versions.count }.by(1) | |||
| expect(response).to have_http_status(:ok) | |||
| version = tag2.reload.tag_versions.order(:version_no).last | |||
| expect(version.version_no).to eq(1) | |||
| expect(version.event_type).to eq('create') | |||
| expect(version.name).to eq('spec_tag_2') | |||
| expect(version.created_by_user_id).to eq(member.id) | |||
| expect(response).to have_http_status(:ok), response.body | |||
| end | |||
| end | |||
| end | |||
| @@ -26,6 +26,7 @@ RSpec.describe 'TagVersions API', type: :request do | |||
| created_by_user:, | |||
| created_at: | |||
| ) | |||
| version = | |||
| TagVersion.create!( | |||
| tag: tag, | |||
| version_no: version_no, | |||
| @@ -35,8 +36,12 @@ RSpec.describe 'TagVersions API', type: :request do | |||
| aliases: Array(aliases).join(' '), | |||
| parent_tag_ids: Array(parent_tags).map(&:id).join(' '), | |||
| 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 | |||
| 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 TopNav from '@/components/TopNav' | |||
| import DialogueProvider from '@/components/dialogues/DialogueProvider' | |||
| import { Toaster } from '@/components/ui/toaster' | |||
| import { apiPost, isApiError } from '@/lib/api' | |||
| import DeerjikistDetailPage from '@/pages/deerjikists/DeerjikistDetailPage' | |||
| @@ -138,7 +139,9 @@ export default (() => { | |||
| return ( | |||
| <> | |||
| <RouteBlockerOverlay/> | |||
| <BrowserRouter> | |||
| <DialogueProvider> | |||
| <LayoutGroup> | |||
| <motion.div | |||
| layout="position" | |||
| @@ -148,7 +151,9 @@ export default (() => { | |||
| <RouteTransitionWrapper user={user} setUser={setUser}/> | |||
| </motion.div> | |||
| </LayoutGroup> | |||
| <Toaster/> | |||
| </DialogueProvider> | |||
| </BrowserRouter> | |||
| </>) | |||
| }) satisfies FC | |||
| @@ -3,11 +3,12 @@ import { useEffect, useState } from 'react' | |||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||
| import Label from '@/components/common/Label' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import { Button } from '@/components/ui/button' | |||
| 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' | |||
| @@ -32,6 +33,7 @@ type Props = { post: Post | |||
| export default (({ post, onSave }: Props) => { | |||
| const [disabled, setDisabled] = useState (false) | |||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | |||
| useState<string | null> (post.originalCreatedBefore) | |||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | |||
| @@ -41,16 +43,14 @@ export default (({ post, onSave }: Props) => { | |||
| const [tags, setTags] = useState<string> ('') | |||
| const [title, setTitle] = useState (post.title) | |||
| const handleSubmit = async () => { | |||
| const dialogue = useDialogue () | |||
| const update = async (...args: Parameters<typeof updatePost>) => { | |||
| 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, | |||
| versionNo: data.versionNo, | |||
| title: data.title, | |||
| tags: data.tags, | |||
| parentPosts: data.parentPosts, | |||
| @@ -60,9 +60,58 @@ export default (({ post, onSave }: Props) => { | |||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||
| toast ({ description: '更新しました.' }) | |||
| } | |||
| catch | |||
| catch (e) | |||
| { | |||
| const response = (e as any)?.response | |||
| if (response?.status !== 409) | |||
| { | |||
| 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]) | |||
| return ( | |||
| <div className="max-w-xl pt-2 space-y-4"> | |||
| <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> | |||
| {/* タイトル */} | |||
| <div> | |||
| <Label>タイトル</Label> | |||
| <input type="text" | |||
| <input | |||
| type="text" | |||
| disabled={disabled} | |||
| className="w-full border rounded p-2" | |||
| value={title ?? ''} | |||
| onChange={ev => setTitle (ev.target.value)}/> | |||
| @@ -86,25 +137,31 @@ export default (({ post, onSave }: Props) => { | |||
| <Label>親投稿</Label> | |||
| <input | |||
| type="text" | |||
| disabled={disabled} | |||
| value={parentPostIds} | |||
| onChange={e => setParentPostIds (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* タグ */} | |||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||
| <PostFormTagsArea | |||
| disabled={disabled} | |||
| tags={tags} | |||
| setTags={setTags}/> | |||
| {/* オリジナルの作成日時 */} | |||
| <PostOriginalCreatedTimeField | |||
| disabled={disabled} | |||
| originalCreatedFrom={originalCreatedFrom} | |||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||
| originalCreatedBefore={originalCreatedBefore} | |||
| 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> | |||
| </div>) | |||
| </form>) | |||
| }) satisfies FC<Props> | |||
| @@ -3,6 +3,7 @@ import YoutubeEmbed from 'react-youtube' | |||
| import NicoViewer from '@/components/NicoViewer' | |||
| import TwitterEmbed from '@/components/TwitterEmbed' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import type { FC, RefObject } from 'react' | |||
| @@ -16,6 +17,8 @@ type Props = { | |||
| export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| const dialogue = useDialogue () | |||
| const url = new URL (post.url) | |||
| switch (url.hostname.split ('.').slice (-2).join ('.')) | |||
| @@ -82,12 +85,17 @@ export default (({ ref, post, onLoadComplete, onMetadataChange }: Props) => { | |||
| height={360}/>) | |||
| : ( | |||
| <div> | |||
| <a href="#" onClick={e => { | |||
| <a href="#" onClick={async e => { | |||
| e.preventDefault () | |||
| setFramed (confirm ('未確認の外部ページを表示します。\n' | |||
| + '悪意のあるスクリプトが実行される可能性があります。\n' | |||
| + '表示しますか?')) | |||
| return | |||
| setFramed (await dialogue.confirm ({ | |||
| title: '未確認の外部ページを表示します。', | |||
| description: ( | |||
| <div> | |||
| <p>悪意のあるスクリプトが実行される可能性があります。</p> | |||
| <p>表示しますか?</p> | |||
| </div>), | |||
| confirmText: '表示' })) | |||
| }}> | |||
| 外部ページを表示 | |||
| </a> | |||
| @@ -7,7 +7,7 @@ import Label from '@/components/common/Label' | |||
| import TextArea from '@/components/common/TextArea' | |||
| import { apiGet } from '@/lib/api' | |||
| import type { FC, SyntheticEvent } from 'react' | |||
| import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' | |||
| 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) }` | |||
| type Props = { | |||
| type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { | |||
| tags: string | |||
| setTags: (tags: string) => void } | |||
| export default (({ tags, setTags }: Props) => { | |||
| export default (({ tags, setTags, ...rest }: Props) => { | |||
| const ref = useRef<HTMLTextAreaElement> (null) | |||
| 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"> | |||
| <Label>タグ</Label> | |||
| <TextArea | |||
| {...rest} | |||
| ref={ref} | |||
| value={tags} | |||
| onChange={ev => setTags (ev.target.value)} | |||
| @@ -42,7 +42,7 @@ export default (({ posts, onClick }: Props) => { | |||
| layoutId={layoutId} | |||
| className={cn ('w-full h-full overflow-hidden rounded-xl shadow', | |||
| 'transform-gpu will-change-transform', | |||
| (post.childPosts ?? []).length > 0 && 'outline-4 outline-green-500', | |||
| (post.childPosts ?? []).length > 0 && 'ring-4 ring-green-500', | |||
| (post.parentPosts ?? []).length > 0 && 'ring-4 ring-yellow-500')} | |||
| whileHover={{ scale: 1.02 }} | |||
| onLayoutAnimationStart={() => { | |||
| @@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button' | |||
| import type { FC } from 'react' | |||
| type Props = { | |||
| disabled?: boolean | |||
| originalCreatedFrom: string | null | |||
| setOriginalCreatedFrom: (x: string | null) => void | |||
| originalCreatedBefore: string | null | |||
| setOriginalCreatedBefore: (x: string | null) => void } | |||
| export default (({ originalCreatedFrom, | |||
| export default (({ disabled, | |||
| originalCreatedFrom, | |||
| setOriginalCreatedFrom, | |||
| originalCreatedBefore, | |||
| setOriginalCreatedBefore }: Props) => ( | |||
| @@ -21,6 +23,7 @@ export default (({ originalCreatedFrom, | |||
| <div className="w-80"> | |||
| <DateTimeField | |||
| className="mr-2" | |||
| disabled={disabled ?? false} | |||
| value={originalCreatedFrom ?? undefined} | |||
| onChange={setOriginalCreatedFrom} | |||
| onBlur={ev => { | |||
| @@ -40,6 +43,7 @@ export default (({ originalCreatedFrom, | |||
| <div> | |||
| <Button | |||
| className="bg-gray-600 text-white rounded" | |||
| disabled={disabled} | |||
| onClick={() => { | |||
| setOriginalCreatedFrom (null) | |||
| }}> | |||
| @@ -51,6 +55,7 @@ export default (({ originalCreatedFrom, | |||
| <div className="w-80"> | |||
| <DateTimeField | |||
| className="mr-2" | |||
| disabled={disabled} | |||
| value={originalCreatedBefore ?? undefined} | |||
| onChange={setOriginalCreatedBefore}/> | |||
| より前 | |||
| @@ -58,6 +63,7 @@ export default (({ originalCreatedFrom, | |||
| <div> | |||
| <Button | |||
| className="bg-gray-600 text-white rounded" | |||
| disabled={disabled} | |||
| onClick={() => { | |||
| setOriginalCreatedBefore (null) | |||
| }}> | |||
| @@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||
| { name: '一覧', to: '/posts' }, | |||
| { name: '検索', to: '/posts/search' }, | |||
| { name: '追加', to: '/posts/new' }, | |||
| { name: '履歴', to: '/posts/changes' }, | |||
| { name: '全体履歴', to: '/posts/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | |||
| { name: 'タグ', to: '/tags', subMenu: [ | |||
| { name: 'マスタ', to: '/tags' }, | |||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | |||
| { name: '履歴', to: '/tags/changes' }, | |||
| { name: '全体履歴', to: '/tags/changes' }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | |||
| { component: <Separator/>, visible: tagFlg }, | |||
| { name: `広場 (${ postCount || 0 })`, | |||
| @@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||
| { name: '一覧', to: '/materials' }, | |||
| { name: '検索', to: '/materials/search', visible: false }, | |||
| { name: '追加', to: '/materials/new' }, | |||
| { name: '履歴', to: '/materials/changes', visible: false }, | |||
| { name: '全体履歴', to: '/materials/changes', visible: false }, | |||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, | |||
| { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ | |||
| { name: <>第 1 会場</>, to: '/theatres/1' }, | |||
| @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' | |||
| 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') | |||
| @@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => { | |||
| } | |||
| type Props = { | |||
| type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { | |||
| value?: string | |||
| onChange?: (isoUTC: string | null) => void | |||
| className?: string | |||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | |||
| export default (({ value, onChange, className, onBlur }: Props) => { | |||
| export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||
| const [local, setLocal] = useState ('') | |||
| useEffect (() => { | |||
| @@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur }: Props) => { | |||
| return ( | |||
| <input | |||
| {...rest} | |||
| className={cn ('border rounded p-2', className)} | |||
| type="datetime-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" | |||
| 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: { | |||
| 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: | |||
| "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: | |||
| "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: | |||
| "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: { | |||
| 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: { | |||
| variant: "default", | |||
| size: "default", | |||
| variant: 'default', | |||
| size: 'default', | |||
| }, | |||
| } | |||
| ) | |||
| }) | |||
| export interface ButtonProps | |||
| extends React.ButtonHTMLAttributes<HTMLButtonElement>, | |||
| @@ -38,24 +38,28 @@ const DialogContent = React.forwardRef< | |||
| <DialogPrimitive.Content | |||
| ref={ref} | |||
| 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%]', | |||
| '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=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]: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)} | |||
| {...props} | |||
| > | |||
| {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.Content> | |||
| </DialogPortal> | |||
| @@ -1,8 +1,11 @@ | |||
| import { useState } from 'react' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import { Button } from '@/components/ui/button' | |||
| import { Dialog, | |||
| DialogContent, | |||
| DialogDescription, | |||
| DialogHeader, | |||
| DialogTitle } from '@/components/ui/dialog' | |||
| import { Input } from '@/components/ui/input' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| @@ -16,10 +19,16 @@ type Props = { visible: boolean | |||
| export default ({ visible, onVisibleChange, setUser }: Props) => { | |||
| const dialogue = useDialogue () | |||
| const [inputCode, setInputCode] = useState ('') | |||
| const handleTransfer = async () => { | |||
| if (!(confirm ('引継ぎを行ってもよろしいですか?\n現在のアカウントからはログアウトされます.'))) | |||
| if (!(await dialogue.confirm ({ | |||
| title: '引継ぎを行ってもよろしいですか?', | |||
| description: '現在のアカウントからはログアウトされます.', | |||
| confirmText: '引継ぐ', | |||
| variant: 'danger' }))) | |||
| return | |||
| try | |||
| @@ -44,14 +53,18 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||
| return ( | |||
| <Dialog open={visible} onOpenChange={onVisibleChange}> | |||
| <DialogContent> | |||
| <DialogContent className="px-6 pp-6 pt-7"> | |||
| <DialogHeader className="pl-8"> | |||
| <DialogTitle>ほかのブラウザから引継ぐ</DialogTitle> | |||
| <DialogDescription asChild> | |||
| <div className="flex gap-2"> | |||
| <Input placeholder="引継ぎコードを入力" | |||
| value={inputCode} | |||
| onChange={ev => setInputCode (ev.target.value)}/> | |||
| <Button onClick={handleTransfer}>引継ぐ</Button> | |||
| </div> | |||
| </DialogDescription> | |||
| </DialogHeader> | |||
| </DialogContent> | |||
| </Dialog>) | |||
| } | |||
| @@ -1,6 +1,10 @@ | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import { Button } from '@/components/ui/button' | |||
| import { Dialog, | |||
| DialogContent, | |||
| DialogDescription, | |||
| DialogFooter, | |||
| DialogHeader, | |||
| DialogTitle } from '@/components/ui/dialog' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { apiPost } from '@/lib/api' | |||
| @@ -14,11 +18,20 @@ type Props = { visible: boolean | |||
| export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||
| const dialogue = useDialogue () | |||
| const handleChange = async () => { | |||
| if (!(user)) | |||
| return | |||
| if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | |||
| if (!(await dialogue.confirm ({ | |||
| title: '引継ぎコードを再発行しますか?', | |||
| description: ( | |||
| <div> | |||
| <p>再発行するとほかのブラウザからはログアウトされます.</p> | |||
| </div>), | |||
| confirmText: '再発行', | |||
| variant: 'danger' }))) | |||
| return | |||
| const data = await apiPost<{ code: string }> ('/users/code/renew', { }, | |||
| @@ -33,21 +46,26 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||
| return ( | |||
| <Dialog open={visible} onOpenChange={onVisibleChange}> | |||
| <DialogContent> | |||
| <DialogContent className="px-6 pb-6 pt-7"> | |||
| <DialogHeader className="pl-8"> | |||
| <DialogTitle>引継ぎコード</DialogTitle> | |||
| <DialogDescription asChild> | |||
| <div> | |||
| <p>あなたの引継ぎコードはこちらです:</p> | |||
| <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> | |||
| <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> | |||
| </div> | |||
| </div> | |||
| </DialogFooter> | |||
| </DialogContent> | |||
| </Dialog>) | |||
| } | |||
| @@ -6,6 +6,56 @@ | |||
| @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 | |||
| { | |||
| @apply overflow-x-clip; | |||
| @@ -54,34 +104,6 @@ body | |||
| 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) | |||
| { | |||
| :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' | |||
| @@ -42,6 +42,30 @@ export const fetchPostChanges = async ( | |||
| 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> => { | |||
| await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | |||
| } | |||
| @@ -8,16 +8,18 @@ import TagLink from '@/components/TagLink' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import PageTitle from '@/components/common/PageTitle' | |||
| import Pagination from '@/components/common/Pagination' | |||
| import { useDialogue } from '@/components/dialogues/DialogueProvider' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { apiPut } from '@/lib/api' | |||
| import { fetchPostChanges } from '@/lib/posts' | |||
| import { fetchPostChanges, updatePost } from '@/lib/posts' | |||
| import { postsKeys, tagsKeys } from '@/lib/queryKeys' | |||
| import { fetchTag } from '@/lib/tags' | |||
| 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 }) => ( | |||
| @@ -34,6 +36,8 @@ const renderDiff = (diff: { current: string | null; prev: string | null }) => ( | |||
| export default (() => { | |||
| const dialogue = useDialogue () | |||
| const location = useLocation () | |||
| const query = new URLSearchParams (location.search) | |||
| const id = query.get ('id') | |||
| @@ -62,6 +66,48 @@ export default (() => { | |||
| 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 (() => { | |||
| document.querySelector ('table')?.scrollIntoView ({ behavior: 'smooth' }) | |||
| }, [location.search]) | |||
| @@ -231,46 +277,7 @@ export default (() => { | |||
| {dateString (change.createdAt)} | |||
| </td> | |||
| <td className="p-2"> | |||
| <a | |||
| href="#" | |||
| onClick={async e => { | |||
| e.preventDefault () | |||
| if (!(confirm ( | |||
| `『${ change.title.current | |||
| || change.url.current }』を版 ${ | |||
| change.versionNo } に差戻します.\nよろしいですか?`))) | |||
| return | |||
| try | |||
| { | |||
| await apiPut ( | |||
| `/posts/${ change.postId }`, | |||
| { title: change.title.current, | |||
| tags: change.tags | |||
| .filter (t => t.type !== 'removed') | |||
| .map (t => t.name) | |||
| .filter (t => t.slice (0, 5) !== 'nico:') | |||
| .join (' '), | |||
| 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> | |||
| </td> | |||
| @@ -121,6 +121,7 @@ export type Platform = typeof PLATFORMS[number] | |||
| export type Post = { | |||
| id: number | |||
| versionNo: number | |||
| url: string | |||
| title: string | null | |||
| thumbnail: string | null | |||
| @@ -146,6 +147,7 @@ export type PostTagChange = { | |||
| export type PostVersion = { | |||
| postId: number | |||
| latestVersionNo: number | |||
| versionNo: number | |||
| eventType: 'create' | 'update' | 'discard' | 'restore' | |||
| title: { current: string | null; prev: string | null } | |||
| @@ -19,7 +19,22 @@ export default { | |||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | |||
| colors: { | |||
| 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: { | |||
| 'rainbow-scroll': { | |||
| '0%': { backgroundPosition: '0% 50%' }, | |||