Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8b028a4dd | |||
| 49c82c626a | |||
| 35e5af2f9a | |||
| 5b50642756 | |||
| de86879e79 | |||
| 772c66aa64 | |||
| d54e66a114 | |||
| b47cdc7ad7 |
@@ -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 = Post.lock.find(params[:id].to_i)
|
||||||
|
|
||||||
post.update!(title:, original_created_from:, original_created_before:)
|
base_version = nil
|
||||||
|
base_snapshot = nil
|
||||||
|
current_snapshot = nil
|
||||||
|
unless force
|
||||||
|
base_version = post.post_versions.find_by!(version_no: base_version_no)
|
||||||
|
|
||||||
normalised_tags = Tag.normalise_tags!(tag_names, with_tagme: false)
|
base_snapshot = post_snapshot_from_version(base_version)
|
||||||
TagVersioning.record_tag_snapshots!(normalised_tags, created_by_user: current_user)
|
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:)
|
||||||
|
|
||||||
tags = post.tags.nico.to_a + normalised_tags
|
snapshot_to_apply =
|
||||||
tags = Tag.expand_parent_tags(tags)
|
if force || post.version_no == base_version_no || current_snapshot == base_snapshot
|
||||||
sync_post_tags!(post, tags)
|
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)
|
||||||
PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
|
else
|
||||||
|
conflict_json = post_conflict_json(post:,
|
||||||
|
base_version_no:,
|
||||||
|
base_snapshot:,
|
||||||
|
current_snapshot:,
|
||||||
|
incoming_snapshot:,
|
||||||
|
changes:,
|
||||||
|
conflicts:)
|
||||||
|
raise ActiveRecord::Rollback
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
apply_post_snapshot!(post, snapshot_to_apply)
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
class UsersController < ApplicationController
|
class UsersController < ApplicationController
|
||||||
def create
|
def create
|
||||||
return head :unprocessable_entity if request.remote_ip.blank?
|
|
||||||
return head :forbidden if ip_address_banned?
|
|
||||||
|
|
||||||
user = nil
|
user = nil
|
||||||
User.transaction do
|
User.transaction do
|
||||||
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
user = User.create!(inheritance_code: SecureRandom.uuid, role: :guest)
|
||||||
@@ -15,9 +12,6 @@ class UsersController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def verify
|
def verify
|
||||||
return head :unprocessable_entity if request.remote_ip.blank?
|
|
||||||
return head :forbidden if ip_address_banned?
|
|
||||||
|
|
||||||
user = User.find_by(inheritance_code: params[:code])
|
user = User.find_by(inheritance_code: params[:code])
|
||||||
return render json: { valid: false } unless user
|
return render json: { valid: false } unless user
|
||||||
return head :forbidden if user.banned?
|
return head :forbidden if user.banned?
|
||||||
@@ -65,11 +59,4 @@ class UsersController < ApplicationController
|
|||||||
|
|
||||||
UserIp.create_or_find_by!(user:, ip_address:)
|
UserIp.create_or_find_by!(user:, ip_address:)
|
||||||
end
|
end
|
||||||
|
|
||||||
def ip_address_banned?
|
|
||||||
ip_address = IpAddress.find_by(ip_address: IPAddr.new(request.remote_ip).hton)
|
|
||||||
return false unless ip_address
|
|
||||||
|
|
||||||
ip_address.banned?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ class IpAddress < ApplicationRecord
|
|||||||
has_many :users, through: :user_ips
|
has_many :users, through: :user_ips
|
||||||
|
|
||||||
def banned? = banned_at.present?
|
def banned? = banned_at.present?
|
||||||
def ban! = banned? or update!(banned_at: Time.current)
|
def ban! = banned? || update!(banned_at: Time.current)
|
||||||
def unban! = update!(banned_at: nil)
|
def unban! = update!(banned_at: nil)
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ class User < ApplicationRecord
|
|||||||
def gte_member? = member? || admin?
|
def gte_member? = member? || admin?
|
||||||
|
|
||||||
def banned? = banned_at.present?
|
def banned? = banned_at.present?
|
||||||
def ban! = banned? or update!(banned_at: Time.current)
|
def ban! = banned? || update!(banned_at: Time.current)
|
||||||
def unban! = update!(banned_at: nil)
|
def unban! = update!(banned_at: nil)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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'
|
validate_version_sequence!(latest)
|
||||||
raise "#{ version_class.name } first event must be create"
|
|
||||||
end
|
|
||||||
|
|
||||||
if @event_type == 'create' && latest
|
|
||||||
raise "#{ version_class.name } create event already exists"
|
|
||||||
end
|
|
||||||
|
|
||||||
attrs = snapshot_attributes
|
attrs = snapshot_attributes
|
||||||
|
|
||||||
return latest if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
if @event_type == 'update' && latest && same_snapshot?(latest, attrs)
|
||||||
|
return latest
|
||||||
|
end
|
||||||
|
|
||||||
version_class.create!(base_attributes(latest).merge(record_key => @record).merge(attrs))
|
version = version_class.create!(
|
||||||
|
base_attributes(latest).merge(record_key => @record).merge(attrs))
|
||||||
|
|
||||||
|
update_record_version_no!(version.version_no)
|
||||||
|
|
||||||
|
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
|
||||||
Generated
+7
-1
@@ -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|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
FactoryBot.define do
|
||||||
|
factory :ip_address do
|
||||||
|
ip_address { IPAddr.new('203.0.113.10').hton }
|
||||||
|
banned_at { nil }
|
||||||
|
|
||||||
|
trait :banned do
|
||||||
|
banned_at { Time.current }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
FactoryBot.define do
|
FactoryBot.define do
|
||||||
factory :user do
|
factory :user do
|
||||||
name { "test-user" }
|
name { nil }
|
||||||
inheritance_code { SecureRandom.uuid }
|
inheritance_code { SecureRandom.uuid }
|
||||||
role { "guest" }
|
role { 'guest' }
|
||||||
|
banned_at { nil }
|
||||||
|
|
||||||
|
trait :guest do
|
||||||
|
role { 'guest' }
|
||||||
|
end
|
||||||
|
|
||||||
trait :member do
|
trait :member do
|
||||||
role { "member" }
|
role { 'member' }
|
||||||
end
|
end
|
||||||
|
|
||||||
trait :admin do
|
trait :admin do
|
||||||
role { 'admin' }
|
role { 'admin' }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :banned do
|
||||||
|
banned_at { Time.current }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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)
|
expect(response).to have_http_status(:ok), response.body
|
||||||
|
|
||||||
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)
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,109 +1,266 @@
|
|||||||
require "rails_helper"
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Users', type: :request do
|
||||||
|
let(:remote_ip) { '203.0.113.10' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow_any_instance_of(ActionDispatch::Request)
|
||||||
|
.to receive(:remote_ip)
|
||||||
|
.and_return(remote_ip)
|
||||||
|
end
|
||||||
|
|
||||||
|
def auth_headers(user)
|
||||||
|
{ 'X-Transfer-Code' => user.inheritance_code }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST /users' do
|
||||||
|
it 'creates guest user, IpAddress and UserIp, and returns code' do
|
||||||
|
expect {
|
||||||
|
post '/users'
|
||||||
|
}.to change(User, :count).by(1)
|
||||||
|
.and change(IpAddress, :count).by(1)
|
||||||
|
.and change(UserIp, :count).by(1)
|
||||||
|
|
||||||
RSpec.describe "Users", type: :request do
|
|
||||||
describe "POST /users" do
|
|
||||||
it "creates guest user and returns code" do
|
|
||||||
post "/users"
|
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
expect(json["code"]).to be_present
|
expect(json['code']).to be_present
|
||||||
expect(json["user"]["role"]).to eq("guest")
|
expect(json['user']['role']).to eq('guest')
|
||||||
|
|
||||||
|
user = User.last
|
||||||
|
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
|
||||||
|
|
||||||
|
expect(user.role).to eq('guest')
|
||||||
|
expect(ip_address).to be_present
|
||||||
|
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 and does not create user when current IP address is banned' do
|
||||||
|
IpAddress.create!(
|
||||||
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
banned_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/users'
|
||||||
|
}.not_to change(User, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
expect(UserIp.count).to eq(0)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "POST /users/code/renew" do
|
describe 'POST /users/code/renew' do
|
||||||
it "returns 401 when not logged in" do
|
it 'returns 401 when not logged in' do
|
||||||
sign_out
|
post '/users/code/renew'
|
||||||
post "/users/code/renew"
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "PUT /users/:id" do
|
|
||||||
let(:user) { create(:user, name: "old-name", role: "guest") }
|
|
||||||
|
|
||||||
it "returns 401 when current_user id mismatch" do
|
|
||||||
sign_in_as(create(:user))
|
|
||||||
put "/users/#{user.id}", params: { name: "new-name" }
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns 400 when name is blank" do
|
it 'returns 403 when current user is banned' do
|
||||||
sign_in_as(user)
|
user = create(:user, :banned)
|
||||||
put "/users/#{user.id}", params: { name: " " }
|
|
||||||
|
post '/users/code/renew', headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when current IP address is banned' do
|
||||||
|
user = create(:user)
|
||||||
|
|
||||||
|
IpAddress.create!(
|
||||||
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
banned_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
post '/users/code/renew', headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT /users/:id' do
|
||||||
|
let(:user) { create(:user, name: 'old-name', role: 'guest') }
|
||||||
|
|
||||||
|
it 'returns 401 when current_user id mismatch' do
|
||||||
|
other_user = create(:user)
|
||||||
|
|
||||||
|
put "/users/#{user.id}",
|
||||||
|
params: { name: 'new-name' },
|
||||||
|
headers: auth_headers(other_user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 400 when name is blank' do
|
||||||
|
put "/users/#{user.id}",
|
||||||
|
params: { name: ' ' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:bad_request)
|
expect(response).to have_http_status(:bad_request)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "updates name and returns 201 with user slice" do
|
it 'updates name and returns user slice' do
|
||||||
sign_in_as(user)
|
put "/users/#{user.id}",
|
||||||
put "/users/#{user.id}", params: { name: "new-name" }
|
params: { name: 'new-name' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json["id"]).to eq(user.id)
|
expect(json['id']).to eq(user.id)
|
||||||
expect(json["name"]).to eq("new-name")
|
expect(json['name']).to eq('new-name')
|
||||||
|
|
||||||
user.reload
|
user.reload
|
||||||
expect(user.name).to eq("new-name")
|
expect(user.name).to eq('new-name')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when current user is banned' do
|
||||||
|
user.update!(banned_at: Time.current)
|
||||||
|
|
||||||
|
put "/users/#{user.id}",
|
||||||
|
params: { name: 'new-name' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
|
||||||
|
user.reload
|
||||||
|
expect(user.name).to eq('old-name')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when current IP address is banned' do
|
||||||
|
IpAddress.create!(
|
||||||
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
banned_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
put "/users/#{user.id}",
|
||||||
|
params: { name: 'new-name' },
|
||||||
|
headers: auth_headers(user)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
|
||||||
|
user.reload
|
||||||
|
expect(user.name).to eq('old-name')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "POST /users/verify" do
|
describe 'POST /users/verify' do
|
||||||
it "returns valid:false when code not found" do
|
it 'returns valid:false when code not found' do
|
||||||
post "/users/verify", params: { code: "nope" }
|
post '/users/verify', params: { code: 'nope' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json["valid"]).to eq(false)
|
expect(json['valid']).to eq(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "creates IpAddress and UserIp, and returns valid:true with user slice" do
|
it 'returns 403 when current IP address is banned' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
|
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
|
||||||
|
|
||||||
# request.remote_ip を固定
|
IpAddress.create!(
|
||||||
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
banned_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
post "/users/verify", params: { code: user.inheritance_code }
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
|
}.not_to change(UserIp, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when verified user is banned' do
|
||||||
|
user = create(
|
||||||
|
:user,
|
||||||
|
:banned,
|
||||||
|
inheritance_code: SecureRandom.uuid,
|
||||||
|
role: 'guest'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
|
}.not_to change(UserIp, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates IpAddress and UserIp, and returns valid:true with user slice' do
|
||||||
|
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
}.to change(UserIp, :count).by(1)
|
}.to change(UserIp, :count).by(1)
|
||||||
|
.and change(IpAddress, :count).by(1)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json["valid"]).to eq(true)
|
expect(json['valid']).to eq(true)
|
||||||
expect(json["user"]["id"]).to eq(user.id)
|
expect(json['user']['id']).to eq(user.id)
|
||||||
expect(json["user"]["inheritance_code"]).to eq(user.inheritance_code)
|
expect(json['user']['inheritance_code']).to eq(user.inheritance_code)
|
||||||
expect(json["user"]["role"]).to eq("guest")
|
expect(json['user']['role']).to eq('guest')
|
||||||
|
|
||||||
# ついでに IpAddress もできてることを確認(ipの保存形式がバイナリでも count で見れる)
|
ip_address = IpAddress.find_by(ip_address: IPAddr.new(remote_ip).hton)
|
||||||
expect(IpAddress.count).to be >= 1
|
expect(ip_address).to be_present
|
||||||
|
expect(UserIp.exists?(user:, ip_address:)).to eq(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "is idempotent for same user+ip (does not create duplicate UserIp)" do
|
it 'is idempotent for same user and same IP address' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, role: "guest")
|
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
|
||||||
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return("203.0.113.10")
|
|
||||||
|
|
||||||
post "/users/verify", params: { code: user.inheritance_code }
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
post "/users/verify", params: { code: user.inheritance_code }
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
}.not_to change(UserIp, :count)
|
}.not_to change(UserIp, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json["valid"]).to eq(true)
|
expect(json['valid']).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates another UserIp for same user and different IP address' do
|
||||||
|
user = create(:user, inheritance_code: SecureRandom.uuid, role: 'guest')
|
||||||
|
|
||||||
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
|
allow_any_instance_of(ActionDispatch::Request)
|
||||||
|
.to receive(:remote_ip)
|
||||||
|
.and_return('203.0.113.11')
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post '/users/verify', params: { code: user.inheritance_code }
|
||||||
|
}.to change(UserIp, :count).by(1)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['valid']).to eq(true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /users/me" do
|
describe 'GET /users/me' do
|
||||||
it "returns 404 when code not found" do
|
it 'returns 404 when code not found' do
|
||||||
get "/users/me", params: { code: "nope" }
|
get '/users/me', params: { code: 'nope' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "returns user slice when found" do
|
it 'returns user slice when found' do
|
||||||
user = create(:user, inheritance_code: SecureRandom.uuid, name: "me", role: "guest")
|
user = create(:user, inheritance_code: SecureRandom.uuid, name: 'me', role: 'guest')
|
||||||
get "/users/me", params: { code: user.inheritance_code }
|
|
||||||
|
get '/users/me', params: { code: user.inheritance_code }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json["id"]).to eq(user.id)
|
expect(json['id']).to eq(user.id)
|
||||||
expect(json["name"]).to eq("me")
|
expect(json['name']).to eq('me')
|
||||||
expect(json["inheritance_code"]).to eq(user.inheritance_code)
|
expect(json['inheritance_code']).to eq(user.inheritance_code)
|
||||||
expect(json["role"]).to eq("guest")
|
expect(json['role']).to eq('guest')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns 403 when current IP address is banned' do
|
||||||
|
user = create(:user, inheritance_code: SecureRandom.uuid)
|
||||||
|
|
||||||
|
IpAddress.create!(
|
||||||
|
ip_address: IPAddr.new(remote_ip).hton,
|
||||||
|
banned_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
get '/users/me', params: { code: user.inheritance_code }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,14 +2,12 @@ module TestRecords
|
|||||||
def create_member_user!
|
def create_member_user!
|
||||||
User.create!(name: 'spec user',
|
User.create!(name: 'spec user',
|
||||||
inheritance_code: SecureRandom.hex(16),
|
inheritance_code: SecureRandom.hex(16),
|
||||||
role: 'member',
|
role: 'member')
|
||||||
banned: false)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_admin_user!
|
def create_admin_user!
|
||||||
User.create!(name: 'spec admin',
|
User.create!(name: 'spec admin',
|
||||||
inheritance_code: SecureRandom.hex(16),
|
inheritance_code: SecureRandom.hex(16),
|
||||||
role: 'admin',
|
role: 'admin')
|
||||||
banned: false)
|
|
||||||
end
|
end
|
||||||
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> (
|
const data = await updatePost (...args)
|
||||||
`/posts/${ post.id }`,
|
|
||||||
{ title, tags, parent_post_ids: parentPostIds,
|
|
||||||
original_created_from: originalCreatedFrom,
|
|
||||||
original_created_before: originalCreatedBefore },
|
|
||||||
{ headers: { 'Content-Type': 'multipart/form-data' } })
|
|
||||||
onSave ({ ...post,
|
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}
|
<Button
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400">
|
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'
|
setFramed (await dialogue.confirm ({
|
||||||
+ '表示しますか?'))
|
title: '未確認の外部ページを表示します。',
|
||||||
return
|
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",
|
'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700',
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
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",
|
default: 'h-10 px-4 py-2',
|
||||||
sm: "h-9 rounded-md px-3",
|
sm: 'h-9 rounded-md px-3',
|
||||||
lg: "h-11 rounded-md px-8",
|
lg: 'h-11 rounded-md px-8',
|
||||||
icon: "h-10 w-10",
|
icon: 'h-10 w-10',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: "default",
|
variant: 'default',
|
||||||
size: "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',
|
'gap-5 rounded-2xl border border-border',
|
||||||
'p-6 shadow-lg duration-200',
|
'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" />
|
<DialogPrimitive.Close
|
||||||
<span className="sr-only">Close</span>
|
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">
|
</div>
|
||||||
<Button onClick={handleChange}
|
</DialogDescription>
|
||||||
className="px-4 py-2 bg-red-600 text-white rounded disabled:bg-gray-400">
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={handleChange} variant="destructive">
|
||||||
引継ぎコード再発行
|
引継ぎコード再発行
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</DialogFooter>
|
||||||
</div>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>)
|
</Dialog>)
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-28
@@ -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, updatePost } from '@/lib/posts'
|
||||||
import { fetchPostChanges } 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
|
<a href="#" onClick={async e => await handleRevert (e, change)}>
|
||||||
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>
|
</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%' },
|
||||||
|
|||||||
Reference in New Issue
Block a user