コミットを比較
24
コミット
main
...
feature/428
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
e187331af4 | ||
|
|
b3b00e170f | ||
|
|
c9628a120d | ||
|
|
525f6883c9 | ||
|
|
20085d5017 | ||
|
|
bbee83e35b | ||
|
|
1dcb790f44 | ||
|
|
b3c42b8eb3 | ||
|
|
a7e350d28e | ||
|
|
d9bfac8392 | ||
|
|
4c1178dab5 | ||
|
|
2d1a8b4730 | ||
|
|
61d7e6f44e | ||
|
|
753a150128 | ||
|
|
cd948611af | ||
|
|
34a4ffefb4 | ||
|
|
eda81cfa89 | ||
|
|
0c1f459d4e | ||
|
|
9be0c7b1d9 | ||
|
|
63860c94af | ||
|
|
851d5e8e09 | ||
|
|
91296f7477 | ||
|
|
f063fc54bb | ||
|
|
b945ceef60 |
@@ -1,10 +1,16 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
|
class UnsupportedLocale < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||||
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
||||||
|
rescue_from(UnsupportedLocale) { head :not_found }
|
||||||
|
|
||||||
before_action :reject_banned_ip_address!
|
before_action :reject_banned_ip_address!
|
||||||
before_action :authenticate_user
|
before_action :authenticate_user
|
||||||
before_action :reject_banned_user!
|
before_action :reject_banned_user!
|
||||||
|
before_action :validate_explicit_locale!
|
||||||
|
|
||||||
def current_user = @current_user
|
def current_user = @current_user
|
||||||
|
|
||||||
@@ -93,5 +99,21 @@ class ApplicationController < ActionController::API
|
|||||||
value
|
value
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_locale! = Locale.find_by(code: params[:locale]) || Locale.nipponese
|
def resolve_locale!
|
||||||
|
return @resolved_locale if defined?(@resolved_locale)
|
||||||
|
|
||||||
|
code = params.fetch(:locale, 'ja')
|
||||||
|
unless code.is_a?(String) && Locale.codes.value?(code)
|
||||||
|
raise UnsupportedLocale
|
||||||
|
end
|
||||||
|
|
||||||
|
locale = Locale.find_by(code:)
|
||||||
|
raise UnsupportedLocale unless locale && locale.deprecated_at.nil?
|
||||||
|
|
||||||
|
@resolved_locale = locale
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_explicit_locale!
|
||||||
|
resolve_locale! if params.key?(:locale)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class LocalesController < ApplicationController
|
||||||
|
def index
|
||||||
|
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
|
||||||
|
{ code: locale.code_before_type_cast, name: locale.name }
|
||||||
|
end
|
||||||
|
|
||||||
|
response.headers['Cache-Control'] = 'no-store'
|
||||||
|
render json: { locales: }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
class PostVersionsController < ApplicationController
|
class PostVersionsController < ApplicationController
|
||||||
def index
|
def index
|
||||||
|
locale = resolve_locale!
|
||||||
post_id = params[:post].presence
|
post_id = params[:post].presence
|
||||||
tag_id = params[:tag].presence&.to_i
|
tag_id = params[:tag].presence&.to_i
|
||||||
external_tag_id = params[:external_tag].presence&.to_i
|
external_tag_id = params[:external_tag].presence&.to_i
|
||||||
@@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
|
|
||||||
render json: { versions: serialise_versions(versions), count: }
|
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def serialise_versions rows
|
def serialise_versions rows, language_code
|
||||||
rows = rows.to_a
|
rows = rows.to_a
|
||||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||||
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
users_by_id = User.where(id: user_ids).pluck(:id, :name).to_h
|
||||||
@@ -58,12 +59,16 @@ class PostVersionsController < ApplicationController
|
|||||||
[normalise_json(row.tags_json),
|
[normalise_json(row.tags_json),
|
||||||
normalise_json(row.attributes['prev_tags_json']) || []]
|
normalise_json(row.attributes['prev_tags_json']) || []]
|
||||||
}
|
}
|
||||||
|
tag_ids = snapshots.flatten.filter_map { _1['tag_id'] }.uniq
|
||||||
|
tag_names = TagName.where(tag_id: tag_ids, language_code:, primary_flg: true)
|
||||||
|
.pluck(:tag_id, :name).to_h
|
||||||
external_tag_names = external_tag_names_for(snapshots)
|
external_tag_names = external_tag_names_for(snapshots)
|
||||||
|
|
||||||
rows.map do |row|
|
rows.map do |row|
|
||||||
cur_tags = snapshot_tag_literals(normalise_json(row.tags_json), external_tag_names)
|
cur_tags = snapshot_tag_literals(
|
||||||
|
normalise_json(row.tags_json), tag_names, external_tag_names)
|
||||||
prev_tags = snapshot_tag_literals(
|
prev_tags = snapshot_tag_literals(
|
||||||
normalise_json(row.attributes['prev_tags_json']) || [], external_tag_names)
|
normalise_json(row.attributes['prev_tags_json']) || [], tag_names, external_tag_names)
|
||||||
|
|
||||||
{ post_id: row.post_id,
|
{ post_id: row.post_id,
|
||||||
version_no: row.version_no,
|
version_no: row.version_no,
|
||||||
@@ -75,6 +80,7 @@ class PostVersionsController < ApplicationController
|
|||||||
prev: row.attributes['prev_thumbnail_base'] },
|
prev: row.attributes['prev_thumbnail_base'] },
|
||||||
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
video_ms: { current: row.video_ms, prev: row.attributes['prev_video_ms'] },
|
||||||
tags: build_version_tags(cur_tags, prev_tags),
|
tags: build_version_tags(cur_tags, prev_tags),
|
||||||
|
parent_post_ids: row.parent_post_ids,
|
||||||
original_created_from: {
|
original_created_from: {
|
||||||
current: row.original_created_from&.iso8601,
|
current: row.original_created_from&.iso8601,
|
||||||
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
prev: row.attributes['prev_original_created_from']&.iso8601 },
|
||||||
@@ -106,17 +112,27 @@ class PostVersionsController < ApplicationController
|
|||||||
names
|
names
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_tag_literals snapshots, external_tag_names
|
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||||
snapshots
|
snapshots
|
||||||
.filter_map { |snapshot|
|
.filter_map { |snapshot|
|
||||||
if snapshot.key?('tag_id')
|
if snapshot.key?('tag_id')
|
||||||
|
sections = snapshot.fetch('sections', []).map { |section|
|
||||||
|
[section.fetch('begin_ms'), section['end_ms']]
|
||||||
|
}.sort_by { |begin_ms, end_ms| [begin_ms, end_ms || Float::INFINITY] }
|
||||||
|
name = tag_names[snapshot['tag_id']] || snapshot.fetch('name')
|
||||||
[tag_category_order(snapshot['category']),
|
[tag_category_order(snapshot['category']),
|
||||||
Post.tag_snapshot_literal(snapshot)]
|
{ identity: [:tag, snapshot['tag_id'], sections],
|
||||||
|
name: Post.tag_snapshot_literal(snapshot.merge('name' => name)),
|
||||||
|
restore_name: tag_names[snapshot['tag_id']] &&
|
||||||
|
Post.tag_snapshot_literal(snapshot.merge('name' => name)),
|
||||||
|
external: false }]
|
||||||
elsif external_tag_names[snapshot['external_tag_id']]
|
elsif external_tag_names[snapshot['external_tag_id']]
|
||||||
[6, external_tag_names[snapshot['external_tag_id']]]
|
[6, { identity: [:external_tag, snapshot['external_tag_id']],
|
||||||
|
name: external_tag_names[snapshot['external_tag_id']],
|
||||||
|
restore_name: nil, external: true }]
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
.sort_by { |order, name| [order, name.downcase] }
|
.sort_by { |order, tag| [order, tag[:name].downcase] }
|
||||||
.map(&:second)
|
.map(&:second)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -125,17 +141,22 @@ class PostVersionsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_version_tags cur_tags, prev_tags
|
def build_version_tags cur_tags, prev_tags
|
||||||
(cur_tags | prev_tags).map do |name|
|
current = cur_tags.index_by { _1[:identity] }
|
||||||
|
previous = prev_tags.index_by { _1[:identity] }
|
||||||
|
|
||||||
|
(current.keys | previous.keys).map do |identity|
|
||||||
type =
|
type =
|
||||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
if current.key?(identity) && previous.key?(identity)
|
||||||
'context'
|
'context'
|
||||||
elsif cur_tags.include?(name)
|
elsif current.key?(identity)
|
||||||
'added'
|
'added'
|
||||||
else
|
else
|
||||||
'removed'
|
'removed'
|
||||||
end
|
end
|
||||||
|
|
||||||
{ name:, type: }
|
entry = current[identity] || previous[identity]
|
||||||
|
{ name: entry[:name], restore_name: entry[:restore_name],
|
||||||
|
external: entry[:external], type: }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -368,6 +368,7 @@ class TagsController < ApplicationController
|
|||||||
return if performed?
|
return if performed?
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
tag.lock!
|
tag.lock!
|
||||||
|
|
||||||
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
requested_keys = rows.map { |row| [row[:platform], row[:code]] }.uniq
|
||||||
|
|||||||
@@ -34,6 +34,34 @@ class WikiPagesController < ApplicationController
|
|||||||
render json:
|
render json:
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def autolink_candidates
|
||||||
|
locale = resolve_locale!
|
||||||
|
language = params.fetch(:language, locale.language_code)
|
||||||
|
return head :not_found unless language.is_a?(String)
|
||||||
|
target_locale =
|
||||||
|
if language == 'und'
|
||||||
|
locale
|
||||||
|
else
|
||||||
|
Locale.where(language_code: language, deprecated_at: nil,
|
||||||
|
code: Locale.codes.values).order(:code).first
|
||||||
|
end
|
||||||
|
return head :not_found unless target_locale
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
unless language == 'und'
|
||||||
|
names = TagDescription.joins(tag: :tag_names)
|
||||||
|
.where(language_code: language, tag_names: { language_code: language })
|
||||||
|
.pluck('tag_descriptions.tag_id', 'tag_descriptions.id', 'tag_names.name')
|
||||||
|
candidates = names.map do |tag_id, description_id, name|
|
||||||
|
WikiPageRepr.autolink_tag(target_locale, tag_id:, description_id:, name:)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
candidates.concat(TagGroup.order(:id).pluck(:id, :name).map do |id, name|
|
||||||
|
WikiPageRepr.autolink_group(target_locale, id:, name:, language:)
|
||||||
|
end)
|
||||||
|
render json: candidates
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
locale = resolve_locale!
|
locale = resolve_locale!
|
||||||
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
tag_description = TagDescription.find_by(id: params[:id], language_code: locale.language_code)
|
||||||
@@ -214,6 +242,7 @@ class WikiPagesController < ApplicationController
|
|||||||
base_revision_id = params[:base_revision_id].presence
|
base_revision_id = params[:base_revision_id].presence
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
tag.lock!
|
tag.lock!
|
||||||
old_title = tag_name.name
|
old_title = tag_name.name
|
||||||
if title != old_title
|
if title != old_title
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
class Language < ApplicationRecord
|
class Language < ApplicationRecord
|
||||||
has_many :locales, foreign_key: :language_code, primary_key: :code
|
has_many :locales, foreign_key: :language_code, primary_key: :code
|
||||||
|
|
||||||
enum :code, ja: 'ja'
|
enum :code, ja: 'ja', ain: 'ain'
|
||||||
end
|
end
|
||||||
|
|||||||
+15
-14
@@ -1,26 +1,27 @@
|
|||||||
class Locale < ApplicationRecord
|
class Locale < ApplicationRecord
|
||||||
after_create_commit :generate_tag_names!
|
attr_accessor :certified_tag_names_path
|
||||||
|
|
||||||
|
before_save :lock_tag_creation!, if: :will_generate_tag_names?
|
||||||
|
after_save :generate_tag_names!, if: :needs_tag_names?
|
||||||
|
|
||||||
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
belongs_to :language, foreign_key: :language_code, primary_key: :code
|
||||||
belongs_to :script, foreign_key: :script_code, primary_key: :code
|
belongs_to :script, foreign_key: :script_code, primary_key: :code
|
||||||
|
|
||||||
enum :code, ja: 'ja', en: 'en', fr: 'fr'
|
enum :code, ja: 'ja', ain_kana: 'ain-Kana'
|
||||||
|
|
||||||
def self.nipponese = Locale.find('ja')
|
def self.nipponese = Locale.find('ja')
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def generate_tag_names!
|
def will_generate_tag_names?
|
||||||
ApplicationRecord.transaction do
|
deprecated_at.nil? && (will_save_change_to_code? || will_save_change_to_deprecated_at?)
|
||||||
tag_ids = TagName.where(language_code:, primary_flg: true).pluck(:tag_id)
|
|
||||||
Tag.where.not(id: tag_ids).includes(:tag_names).find_each do
|
|
||||||
TagName.create!(tag_id: _1.id,
|
|
||||||
language_code:,
|
|
||||||
name: TagName.generate_name(self, _1, _1.tag_name('ja')),
|
|
||||||
script_code:,
|
|
||||||
primary_flg: true,
|
|
||||||
auto_generated: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def lock_tag_creation! = SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
|
||||||
|
def needs_tag_names?
|
||||||
|
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_tag_names! = TagNameCompletion.for_locale!(self)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
class Script < ApplicationRecord
|
class Script < ApplicationRecord
|
||||||
has_many :locales, foreign_key: :script_code, primary_key: :code
|
has_many :locales, foreign_key: :script_code, primary_key: :code
|
||||||
|
|
||||||
enum :code, Jpan: 'Jpan'
|
enum :code, jpan: 'Jpan', kana: 'Kana'
|
||||||
end
|
end
|
||||||
|
|||||||
+11
-29
@@ -110,7 +110,7 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
|
||||||
|
|
||||||
def snapshot_aliases(language_code)
|
def snapshot_aliases language_code
|
||||||
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
tag_names.where(language_code:, primary_flg: false).order(:name).pluck(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -259,20 +259,24 @@ class Tag < ApplicationRecord
|
|||||||
name = name.to_s.strip
|
name = name.to_s.strip
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
tn = TagName.find_by(language_code:, name:)
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
tn = TagName.lock.find_by(language_code:, name:)
|
||||||
next tn.tag if tn
|
next tn.tag if tn
|
||||||
|
|
||||||
tag = Tag.create!(category:)
|
tag = Tag.create!(category:)
|
||||||
tn = TagName.create!(tag:, language_code:, name:,
|
TagName.create!(tag:, language_code:, name:,
|
||||||
script_code: locale.script_code, primary_flg: true,
|
script_code: locale.script_code, primary_flg: true,
|
||||||
auto_generated: false)
|
auto_generated: false)
|
||||||
generate_tag_names!(locale, tag, tn)
|
TagNameCompletion.for_tag!(tag)
|
||||||
tag.association(:tag_names).reset
|
tag.association(:tag_names).reset
|
||||||
|
|
||||||
tag
|
tag
|
||||||
end
|
end
|
||||||
rescue ActiveRecord::RecordNotUnique
|
rescue ActiveRecord::RecordNotUnique
|
||||||
retry
|
existing = TagName.find_by(language_code:, name:)
|
||||||
|
raise unless existing
|
||||||
|
|
||||||
|
existing.tag
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_tags! target_tag, source_tags, created_by_user: nil
|
def merge_tags! target_tag, source_tags, created_by_user: nil
|
||||||
@@ -352,28 +356,6 @@ class Tag < ApplicationRecord
|
|||||||
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
total_s * 1_000 + match[:ms].to_s.ljust(3, '0')[0, 3].to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_tag_names! locale, tag, base_tag_name
|
|
||||||
ApplicationRecord.transaction do
|
|
||||||
locales =
|
|
||||||
Locale
|
|
||||||
.where(deprecated_at: nil)
|
|
||||||
.where.not(language_code: locale.language_code)
|
|
||||||
.order(:language_code, :created_at, :script_code)
|
|
||||||
.uniq { _1.language_code }
|
|
||||||
locales.each do |locale|
|
|
||||||
language_code = locale.language_code
|
|
||||||
script_code = locale.script_code
|
|
||||||
TagName.create!(tag_id: tag.id,
|
|
||||||
language_code:,
|
|
||||||
name: TagName.generate_name(locale, tag, base_tag_name),
|
|
||||||
script_code:,
|
|
||||||
primary_flg: true,
|
|
||||||
auto_generated: true)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -34,9 +34,22 @@ class TagName < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.generate_name locale, tag, base_tag_name
|
def self.generate_name locale, tag, base_tag_name
|
||||||
# TODO: 言語ごとの自動命名ロジック完成したら書く.
|
fallback = "Tag_##{ tag.id }"
|
||||||
|
name =
|
||||||
"Tag_##{ tag.id }"
|
case [locale.language_code, base_tag_name&.language_code]
|
||||||
|
when ['ain', 'ja']
|
||||||
|
TagNameTranscription.ainu(base_tag_name.name)
|
||||||
|
when ['ja', 'ain']
|
||||||
|
base_tag_name.name
|
||||||
|
else
|
||||||
|
return fallback
|
||||||
|
end
|
||||||
|
candidate = new(tag:, name:, language_code: locale.language_code,
|
||||||
|
script_code: locale.script_code,
|
||||||
|
primary_flg: true, auto_generated: true)
|
||||||
|
candidate.valid? ? name : fallback
|
||||||
|
rescue TagNameTranscription::Unconvertible
|
||||||
|
fallback
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -54,6 +67,8 @@ class TagName < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def name_must_be_sanitised
|
def name_must_be_sanitised
|
||||||
|
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||||
|
|
||||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ module WikiPageRepr
|
|||||||
|
|
||||||
{ 'id' => description.id,
|
{ 'id' => description.id,
|
||||||
'resource_type' => 'tag_description',
|
'resource_type' => 'tag_description',
|
||||||
|
'tag_id' => tag.id,
|
||||||
|
'language_code' => description.language_code,
|
||||||
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
'tag_name_id' => tag.tag_name(locale.language_code)&.id,
|
||||||
'body' => description.body,
|
'body' => description.body,
|
||||||
'created_user_id' => description.created_by_user_id,
|
'created_user_id' => description.created_by_user_id,
|
||||||
@@ -24,4 +26,24 @@ module WikiPageRepr
|
|||||||
def tag_group tag_group
|
def tag_group tag_group
|
||||||
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
TagGroupRepr.tag_group(tag_group).merge('resource_type' => 'tag_group')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def autolink_tag locale, tag_id:, description_id:, name:
|
||||||
|
{ resource_type: 'tag_description',
|
||||||
|
resource_id: description_id,
|
||||||
|
tag_id:,
|
||||||
|
tag_description_id: description_id,
|
||||||
|
name:,
|
||||||
|
language_code: locale.language_code,
|
||||||
|
locale_code: locale.code_before_type_cast }
|
||||||
|
end
|
||||||
|
|
||||||
|
def autolink_group locale, id:, name:, language:
|
||||||
|
{ resource_type: 'tag_group',
|
||||||
|
resource_id: id,
|
||||||
|
tag_id: nil,
|
||||||
|
tag_description_id: nil,
|
||||||
|
name: "#{ TagGroupRepr::PREFIX }#{ name }",
|
||||||
|
language_code: language,
|
||||||
|
locale_code: locale.code_before_type_cast }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
require 'json'
|
||||||
|
require 'pathname'
|
||||||
|
|
||||||
|
|
||||||
|
class CertifiedTagNameTranslations
|
||||||
|
FORMAT = 'btrc-hub.certified-tag-name-translations.v1'.freeze
|
||||||
|
|
||||||
|
class Error < ArgumentError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.load! path, locale:
|
||||||
|
new(path, locale:).load!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize path, locale:
|
||||||
|
@path = path
|
||||||
|
@locale = locale
|
||||||
|
end
|
||||||
|
|
||||||
|
def load!
|
||||||
|
document = read_document
|
||||||
|
unless document.is_a?(Hash) && document['format'] == FORMAT
|
||||||
|
fail!('invalid format or version')
|
||||||
|
end
|
||||||
|
|
||||||
|
source_code = document['source_locale']
|
||||||
|
target_code = document['target_locale']
|
||||||
|
unless target_code.is_a?(String) && target_code == Locale.codes.fetch(@locale.code)
|
||||||
|
fail!("target_locale #{ target_code.inspect } does not match the new Locale")
|
||||||
|
end
|
||||||
|
unless source_code.is_a?(String) && Locale.codes.value?(source_code)
|
||||||
|
fail!("invalid source_locale #{ source_code.inspect }")
|
||||||
|
end
|
||||||
|
source_locale = Locale.find_by(code: source_code)
|
||||||
|
fail!("source Locale #{ source_code.inspect } does not exist") unless source_locale
|
||||||
|
fail!('source_locale and target_locale must differ') if source_code == target_code
|
||||||
|
|
||||||
|
entries = validate_entries(document['entries'], source_code, target_code)
|
||||||
|
resolve_tags(entries, source_locale.language_code)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def read_document
|
||||||
|
unless (@path.is_a?(String) && !@path.include?("\0") &&
|
||||||
|
Pathname.new(@path).absolute?)
|
||||||
|
fail!('certified_tag_names_path must be an absolute file path')
|
||||||
|
end
|
||||||
|
fail!('JSON file does not exist or is not a regular file') unless File.file?(@path)
|
||||||
|
|
||||||
|
text = File.read(@path, encoding: Encoding::UTF_8)
|
||||||
|
fail!('JSON file is not valid UTF-8') unless text.valid_encoding?
|
||||||
|
JSON.parse(text)
|
||||||
|
rescue JSON::ParserError, SystemCallError, IOError, EncodingError => error
|
||||||
|
fail!("cannot read JSON: #{ error.message }")
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_entries entries, source_code, target_code
|
||||||
|
fail!('entries must be an array') unless entries.is_a?(Array)
|
||||||
|
|
||||||
|
sources = {}
|
||||||
|
targets = {}
|
||||||
|
entries.each_with_index.map do |entry, index|
|
||||||
|
fail!("entry #{ index + 1 } must be an object") unless entry.is_a?(Hash)
|
||||||
|
source = entry[source_code]
|
||||||
|
target = entry[target_code]
|
||||||
|
unless source.is_a?(String) && source.present? && target.is_a?(String) && target.present?
|
||||||
|
fail!("entry #{ index + 1 }: missing or empty #{ source_code }/#{ target_code } name")
|
||||||
|
end
|
||||||
|
fail!("duplicate source name #{ source.inspect }") if sources.key?(source)
|
||||||
|
fail!("duplicate target name #{ target.inspect }") if targets.key?(target)
|
||||||
|
|
||||||
|
sources[source] = true
|
||||||
|
targets[target] = true
|
||||||
|
[source, target]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_tags entries, source_language
|
||||||
|
source_names = entries.map(&:first)
|
||||||
|
# SQL may use a case/accent-insensitive collation; the Ruby keys are exact.
|
||||||
|
matches = TagName.where(language_code: source_language, primary_flg: true,
|
||||||
|
name: source_names).pluck(:name, :tag_id).group_by(&:first)
|
||||||
|
translations = {}
|
||||||
|
entries.each do |source, target|
|
||||||
|
rows = matches.fetch(source, [])
|
||||||
|
unless rows.length == 1
|
||||||
|
fail!("source primary name #{ source.inspect } must match exactly one tag")
|
||||||
|
end
|
||||||
|
tag_id = rows.first.last
|
||||||
|
if translations.key?(tag_id)
|
||||||
|
fail!("multiple source primary names resolve to Tag##{ tag_id }: #{ source.inspect }")
|
||||||
|
end
|
||||||
|
translations[tag_id] = target
|
||||||
|
end
|
||||||
|
translations
|
||||||
|
end
|
||||||
|
|
||||||
|
def fail! message
|
||||||
|
raise Error, "Certified tag names #{@path.inspect}: #{ message }"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -13,42 +13,14 @@ class SharedResourceIdAllocator
|
|||||||
['tag_description_versions', 'tag_id']],
|
['tag_description_versions', 'tag_id']],
|
||||||
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
'wiki' => [['tag_group_versions', 'tag_group_id']] }.freeze
|
||||||
LOCK_TIMEOUT = 10
|
LOCK_TIMEOUT = 10
|
||||||
|
TRANSACTION_LOCKS = ObjectSpace::WeakMap.new
|
||||||
|
|
||||||
def self.assign! record
|
def self.assign! record
|
||||||
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
space, tables = SPACES.find { |_name, names| names.include?(record.class.table_name) }
|
||||||
raise ArgumentError, 'Unknown shared ID resource' unless tables
|
raise ArgumentError, 'Unknown shared ID resource' unless tables
|
||||||
|
|
||||||
connection = record.class.connection
|
connection = record.class.connection
|
||||||
transaction = record.class.current_transaction
|
lock_space!(record.class, space)
|
||||||
unless transaction.open?
|
|
||||||
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
|
|
||||||
end
|
|
||||||
|
|
||||||
database = connection.pool.db_config.database
|
|
||||||
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
|
||||||
quoted_name = connection.quote(name)
|
|
||||||
acquired = record.class.uncached do
|
|
||||||
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
|
|
||||||
end
|
|
||||||
unless acquired.to_i == 1
|
|
||||||
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
|
|
||||||
end
|
|
||||||
|
|
||||||
release = lambda do
|
|
||||||
result = record.class.uncached do
|
|
||||||
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
|
||||||
end
|
|
||||||
unless result.to_i == 1
|
|
||||||
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
begin
|
|
||||||
transaction.after_commit(&release)
|
|
||||||
transaction.after_rollback(&release)
|
|
||||||
rescue StandardError
|
|
||||||
release.call
|
|
||||||
raise
|
|
||||||
end
|
|
||||||
|
|
||||||
# 明示-tic な Id. 指定は許可する.
|
# 明示-tic な Id. 指定は許可する.
|
||||||
return if record.id.present?
|
return if record.id.present?
|
||||||
@@ -66,6 +38,47 @@ class SharedResourceIdAllocator
|
|||||||
record.id = next_id
|
record.id = next_id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.lock_space! record_class, space
|
||||||
|
raise ArgumentError, 'Unknown shared ID resource' unless SPACES.key?(space)
|
||||||
|
|
||||||
|
connection = record_class.connection
|
||||||
|
transaction = record_class.current_transaction
|
||||||
|
unless transaction.open?
|
||||||
|
raise ActiveRecord::ActiveRecordError, 'Shared ID requires a transaction'
|
||||||
|
end
|
||||||
|
|
||||||
|
database = connection.pool.db_config.database
|
||||||
|
name = "btrc:ids:#{ Digest::SHA256.hexdigest(database)[0, 16] }:#{ space }"
|
||||||
|
held_locks = TRANSACTION_LOCKS[transaction] ||= {}
|
||||||
|
return if held_locks.key?(name)
|
||||||
|
|
||||||
|
quoted_name = connection.quote(name)
|
||||||
|
acquired = record_class.uncached do
|
||||||
|
connection.select_value("SELECT GET_LOCK(#{ quoted_name }, #{ LOCK_TIMEOUT })")
|
||||||
|
end
|
||||||
|
unless acquired.to_i == 1
|
||||||
|
raise ActiveRecord::LockWaitTimeout, "Shared ID lock unavailable: #{ space }"
|
||||||
|
end
|
||||||
|
held_locks[name] = true
|
||||||
|
|
||||||
|
release = lambda do
|
||||||
|
held_locks.delete(name)
|
||||||
|
result = record_class.uncached do
|
||||||
|
connection.select_value("SELECT RELEASE_LOCK(#{ quoted_name })")
|
||||||
|
end
|
||||||
|
unless result.to_i == 1
|
||||||
|
raise ActiveRecord::ActiveRecordError, "Shared ID lock release failed: #{ space }"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
begin
|
||||||
|
transaction.after_commit(&release)
|
||||||
|
transaction.after_rollback(&release)
|
||||||
|
rescue StandardError
|
||||||
|
release.call
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def self.next_ids connection, tables
|
def self.next_ids connection, tables
|
||||||
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
|
expiry = connection.select_value('SELECT @@SESSION.information_schema_stats_expiry').to_i
|
||||||
begin
|
begin
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class TagDescriptionOperation
|
|||||||
|
|
||||||
def self.create! locale, tag:, body:, created_by_user:, message: nil
|
def self.create! locale, tag:, body:, created_by_user:, message: nil
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
tag.lock!
|
tag.lock!
|
||||||
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
raise ActiveRecord::RecordNotUnique if TagDescription.exists?(
|
||||||
tag_id: tag.id, language_code: locale.language_code)
|
tag_id: tag.id, language_code: locale.language_code)
|
||||||
@@ -19,6 +20,7 @@ class TagDescriptionOperation
|
|||||||
|
|
||||||
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
def self.update! locale, tag:, body:, updated_by_user:, message: nil, base_revision_id: nil
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
tag.lock!
|
tag.lock!
|
||||||
description = TagDescription.lock.find_by!(
|
description = TagDescription.lock.find_by!(
|
||||||
tag_id: tag.id, language_code: locale.language_code)
|
tag_id: tag.id, language_code: locale.language_code)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
class TagNameCompletion
|
||||||
|
class << self
|
||||||
|
def for_locale! locale
|
||||||
|
completed = Tag.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
certified_names =
|
||||||
|
if locale.certified_tag_names_path.nil?
|
||||||
|
{ }
|
||||||
|
else
|
||||||
|
CertifiedTagNameTranslations.load!(locale.certified_tag_names_path, locale:)
|
||||||
|
end
|
||||||
|
Tag.where(id: certified_names.keys).lock.find_each(order: :asc) do |tag|
|
||||||
|
for_tag!(tag, locales: [locale],
|
||||||
|
certified_names: { locale.language_code => certified_names.fetch(tag.id) })
|
||||||
|
end
|
||||||
|
named_tags =
|
||||||
|
TagName.where(language_code: locale.language_code, primary_flg: true)
|
||||||
|
.select(:tag_id)
|
||||||
|
Tag.where.not(id: named_tags).lock.find_each(order: :asc) do |tag|
|
||||||
|
for_tag!(tag, locales: [locale])
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
unless completed
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Locale tag completion was rolled back'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def for_tag! tag, locales: nil, certified_names: { }
|
||||||
|
locales ||=
|
||||||
|
Locale.where(deprecated_at: nil)
|
||||||
|
.order(:language_code, :created_at, :script_code).lock
|
||||||
|
Tag.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
completed = tag.with_lock do
|
||||||
|
primary_names = tag.tag_names.where(primary_flg: true).lock.index_by(&:language_code)
|
||||||
|
certified_names.each do |language, name|
|
||||||
|
primary = primary_names[language]
|
||||||
|
next unless primary
|
||||||
|
next if primary.name == name && !primary.auto_generated
|
||||||
|
|
||||||
|
raise CertifiedTagNameTranslations::Error,
|
||||||
|
"Tag##{ tag.id } already has a primary #{ language } name; " +
|
||||||
|
"cannot register certified name #{ name.inspect }"
|
||||||
|
end
|
||||||
|
missing_locales = locales.to_a.uniq(&:language_code).reject { |locale|
|
||||||
|
primary_names.key?(locale.language_code)
|
||||||
|
}
|
||||||
|
next true if missing_locales.empty?
|
||||||
|
|
||||||
|
TagVersioning.ensure_snapshot!(tag, created_by_user: nil)
|
||||||
|
missing_locales.each do |locale|
|
||||||
|
primary_names[locale.language_code] =
|
||||||
|
if certified_names.key?(locale.language_code)
|
||||||
|
create_certified_name!(locale, tag, certified_names.fetch(locale.language_code))
|
||||||
|
else
|
||||||
|
source_language = locale.language_code == 'ja' ? 'ain' : 'ja'
|
||||||
|
name = TagName.generate_name(locale, tag, primary_names[source_language])
|
||||||
|
create_name!(locale, tag, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
tag.association(:tag_names).reset
|
||||||
|
version = TagVersioning.record!(tag, event_type: :update, created_by_user: nil)
|
||||||
|
unless version&.persisted?
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Tag version was not persisted'
|
||||||
|
end
|
||||||
|
true
|
||||||
|
end
|
||||||
|
unless completed
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Tag completion was rolled back'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_certified_name! locale, tag, name
|
||||||
|
persist_name!(locale, tag, name, auto_generated: false)
|
||||||
|
rescue ActiveRecord::RecordInvalid => error
|
||||||
|
raise unless error.record.is_a?(TagName)
|
||||||
|
|
||||||
|
raise CertifiedTagNameTranslations::Error,
|
||||||
|
"Tag##{ tag.id } certified name #{ name.inspect }: " +
|
||||||
|
error.record.errors.full_messages.join(', ')
|
||||||
|
rescue ActiveRecord::RecordNotUnique => error
|
||||||
|
raise CertifiedTagNameTranslations::Error,
|
||||||
|
"Tag##{ tag.id } certified name #{ name.inspect } conflicts: #{ error.message }"
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_name! locale, tag, name
|
||||||
|
persist_name!(locale, tag, name, auto_generated: true)
|
||||||
|
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
|
||||||
|
fallback = "Tag_##{ tag.id }"
|
||||||
|
invalid_name =
|
||||||
|
if error.is_a?(ActiveRecord::RecordNotUnique)
|
||||||
|
TagName.where(language_code: locale.language_code, name:).lock.exists?
|
||||||
|
else
|
||||||
|
error.record.errors[:name].present? &&
|
||||||
|
error.record.errors.attribute_names.all? { _1 == :name }
|
||||||
|
end
|
||||||
|
raise if name == fallback || !(invalid_name)
|
||||||
|
|
||||||
|
persist_name!(locale, tag, fallback, auto_generated: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_name! locale, tag, name, auto_generated:
|
||||||
|
tag_name = TagName.transaction(requires_new: true) do
|
||||||
|
TagName.create!(tag:, name:, language_code: locale.language_code,
|
||||||
|
script_code: locale.script_code, primary_flg: true, auto_generated:)
|
||||||
|
end
|
||||||
|
unless tag_name&.persisted?
|
||||||
|
raise ActiveRecord::RecordNotSaved, 'Generated tag name was not persisted'
|
||||||
|
end
|
||||||
|
tag_name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
module TagNameTranscription
|
||||||
|
class Unconvertible < StandardError
|
||||||
|
;
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ainu name
|
||||||
|
previous_script = nil
|
||||||
|
parts = []
|
||||||
|
NipponeseSegments.split(name).each do |script, text|
|
||||||
|
output = transcribe_segment(script, text)
|
||||||
|
if (parts.last && output.first && previous_script && script != previous_script &&
|
||||||
|
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
|
||||||
|
parts << '_'
|
||||||
|
end
|
||||||
|
|
||||||
|
parts.concat(output)
|
||||||
|
previous_script = script
|
||||||
|
end
|
||||||
|
|
||||||
|
replace_unconvertible(parts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.transcribe_segment script, text
|
||||||
|
return [text] if script == :literal
|
||||||
|
return [nil] if script == :unconvertible
|
||||||
|
return transcribe_reading(text) unless script == :kanzi
|
||||||
|
|
||||||
|
text.each_char.flat_map do |character|
|
||||||
|
transcribe_reading(JisReadings.read(character))
|
||||||
|
rescue Unconvertible
|
||||||
|
[nil]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.transcribe_reading reading
|
||||||
|
Phonology.adapt(reading).map do |sound|
|
||||||
|
sound && Katakana.render([sound])
|
||||||
|
rescue Unconvertible
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.replace_unconvertible parts
|
||||||
|
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
|
||||||
|
unconvertible ? [nil] : group
|
||||||
|
}
|
||||||
|
first = grouped.index { _1&.match?(/[^_]/) }
|
||||||
|
last = grouped.rindex { _1&.match?(/[^_]/) }
|
||||||
|
|
||||||
|
grouped.each_with_index.map { |part, index|
|
||||||
|
part || (first && first < index && index < last ? '_' : '')
|
||||||
|
}.join
|
||||||
|
end
|
||||||
|
|
||||||
|
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
|
||||||
|
end
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module TagNameTranscription
|
||||||
|
module JisReadings
|
||||||
|
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
|
||||||
|
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
|
||||||
|
character, reading, = line.split("\t")
|
||||||
|
[character, reading]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
def self.read text
|
||||||
|
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
module TagNameTranscription
|
||||||
|
module Katakana
|
||||||
|
ROWS = {
|
||||||
|
'' => 'アイウエオ',
|
||||||
|
'k' => 'カキクケコ',
|
||||||
|
's' => 'サシスセソ',
|
||||||
|
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
|
||||||
|
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
|
||||||
|
'n' => 'ナニヌネノ',
|
||||||
|
'h' => 'ハヒフヘホ',
|
||||||
|
'p' => 'パピプペポ',
|
||||||
|
'm' => 'マミムメモ',
|
||||||
|
'r' => 'ラリルレロ' }.freeze
|
||||||
|
SYLLABLES = ROWS.flat_map { |onset, letters|
|
||||||
|
kana = letters.is_a?(String) ? letters.chars : letters
|
||||||
|
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
|
||||||
|
}.to_h.merge(
|
||||||
|
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
|
||||||
|
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
|
||||||
|
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
|
||||||
|
|
||||||
|
def self.render sounds
|
||||||
|
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
module TagNameTranscription
|
||||||
|
module NipponeseSegments
|
||||||
|
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
|
||||||
|
|
||||||
|
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
|
||||||
|
|
||||||
|
def self.split name
|
||||||
|
segments = []
|
||||||
|
name.to_s.scan(/\X/).each do |character|
|
||||||
|
script = classify(character, segments.last&.first)
|
||||||
|
if segments.last&.first == script
|
||||||
|
segments.last[1] << character
|
||||||
|
else
|
||||||
|
segments << [script, character.dup]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
segments
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.classify character, previous_script
|
||||||
|
return previous_script if character == 'ー' && nipponese?(previous_script)
|
||||||
|
return :kanzi if character.match?(/\A\p{Han}\z/)
|
||||||
|
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
|
||||||
|
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
|
||||||
|
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
|
||||||
|
|
||||||
|
:unconvertible
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
module TagNameTranscription
|
||||||
|
module Phonology
|
||||||
|
ROWS = {
|
||||||
|
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
|
||||||
|
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||||
|
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||||
|
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
|
||||||
|
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
|
||||||
|
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||||
|
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||||
|
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
|
||||||
|
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
|
||||||
|
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||||
|
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||||
|
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
|
||||||
|
'ヤユヨ' => ['ya', 'yu', 'yo'],
|
||||||
|
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
|
||||||
|
'ワ' => ['wa'],
|
||||||
|
'ン' => ['n'],
|
||||||
|
'ッ' => ['t'],
|
||||||
|
'ㇰ' => ['k'],
|
||||||
|
'ㇱㇲ' => ['s', 's'],
|
||||||
|
'ㇳ' => ['t'],
|
||||||
|
'ㇴ' => ['n'],
|
||||||
|
'ㇺ' => ['m'],
|
||||||
|
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
|
||||||
|
|
||||||
|
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
|
||||||
|
|
||||||
|
DIGRAPHS = {
|
||||||
|
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
|
||||||
|
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
|
||||||
|
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
|
||||||
|
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
|
||||||
|
|
||||||
|
def self.adapt reading
|
||||||
|
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
|
||||||
|
characters = kana.scan(/\X/)
|
||||||
|
sounds = []
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
while index < characters.length
|
||||||
|
pair = characters[index, 2].join
|
||||||
|
|
||||||
|
if DIGRAPHS.key?(pair)
|
||||||
|
sounds << DIGRAPHS.fetch(pair)
|
||||||
|
index += 2
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
character = characters[index].tr('ャュョ', 'ヤユヨ')
|
||||||
|
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
|
||||||
|
index += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
sounds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
class TagVersionRecorder < VersionRecorder
|
class TagVersionRecorder < VersionRecorder
|
||||||
def self.record! tag:, event_type:, created_by_user:, **_options
|
def self.record! tag:, event_type:, created_by_user:, **_options
|
||||||
new(tag:, event_type:, created_by_user:).record!
|
Tag.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
new(tag:, event_type:, created_by_user:).record!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize tag:, event_type:, created_by_user:
|
def initialize tag:, event_type:, created_by_user:
|
||||||
@@ -15,18 +18,21 @@ class TagVersionRecorder < VersionRecorder
|
|||||||
|
|
||||||
def snapshot_attributes
|
def snapshot_attributes
|
||||||
names = snapshot_names
|
names = snapshot_names
|
||||||
compatibility_names = names.fetch('ja')
|
compatibility_names = names['ja'] || names.fetch('ain')
|
||||||
|
parent_ids =
|
||||||
|
TagImplication.where(tag_id: @record.id).order(:parent_tag_id)
|
||||||
|
.lock.pluck(:parent_tag_id)
|
||||||
|
|
||||||
{ name: compatibility_names.fetch('name'),
|
{ name: compatibility_names.fetch('name'),
|
||||||
category: @record.category,
|
category: @record.category,
|
||||||
deprecated_at: @record.deprecated_at,
|
deprecated_at: @record.deprecated_at,
|
||||||
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
|
aliases: compatibility_names.fetch('aliases').map { _1.fetch('name') }.join(' '),
|
||||||
names:,
|
names:,
|
||||||
parent_tag_ids: @record.snapshot_parent_tag_ids.join(' ') }
|
parent_tag_ids: parent_ids.join(' ') }
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot_names
|
def snapshot_names
|
||||||
@record.tag_names.order(:language_code, :name).group_by(&:language_code)
|
@record.tag_names.order(:language_code, :name).lock.group_by(&:language_code)
|
||||||
.transform_values do |tag_names|
|
.transform_values do |tag_names|
|
||||||
primary = tag_names.find(&:primary?)
|
primary = tag_names.find(&:primary?)
|
||||||
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
|
aliases = tag_names.reject(&:primary?).sort_by(&:name).map { |tag_name|
|
||||||
|
|||||||
@@ -4,14 +4,23 @@ class TagVersioning
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
|
def self.ensure_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||||
return if tag.tag_versions.exists?
|
Tag.transaction do
|
||||||
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
locked_tag = Tag.unscoped.lock.find(tag.id)
|
||||||
|
next if locked_tag.tag_versions.lock.exists?
|
||||||
|
|
||||||
TagVersionRecorder.record!(tag:, event_type: :create, created_by_user:, language_code:)
|
TagVersionRecorder.record!(
|
||||||
|
tag: locked_tag, event_type: :create, created_by_user:, language_code:)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
|
def self.record_tag_snapshot! tag, created_by_user:, language_code: 'ja'
|
||||||
event_type = tag.tag_versions.exists? ? :update : :create
|
Tag.transaction do
|
||||||
record!(tag, event_type:, created_by_user:, language_code:)
|
SharedResourceIdAllocator.lock_space!(Tag, 'tags')
|
||||||
|
locked_tag = Tag.unscoped.lock.find(tag.id)
|
||||||
|
event_type = locked_tag.tag_versions.lock.exists? ? :update : :create
|
||||||
|
record!(locked_tag, event_type:, created_by_user:, language_code:)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
|
def self.record_tag_snapshots! tags, created_by_user:, language_code: 'ja'
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class VersionRecorder
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def latest_version = versions.order(version_no: :desc).first
|
def latest_version = versions.order(version_no: :desc).lock.first
|
||||||
|
|
||||||
def versions = @record.public_send(version_association)
|
def versions = @record.public_send(version_association)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
resources :locales, only: [:index]
|
||||||
|
|
||||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||||
|
|
||||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||||
@@ -43,6 +45,7 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
get :search, action: :index
|
get :search, action: :index
|
||||||
get :changes
|
get :changes
|
||||||
|
get :autolink_candidates
|
||||||
|
|
||||||
scope :title do
|
scope :title do
|
||||||
get ':title/exists', action: :exists_by_title
|
get ':title/exists', action: :exists_by_title
|
||||||
|
|||||||
ファイル差分が大きすぎるため省略します
差分を読み込み
@@ -8,6 +8,11 @@
|
|||||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||||
# end
|
# end
|
||||||
|
|
||||||
|
ApplicationRecord.transaction do
|
||||||
|
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||||
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||||
|
end
|
||||||
|
|
||||||
post_url_sanitisation_rules = [
|
post_url_sanitisation_rules = [
|
||||||
{ priority: 10,
|
{ priority: 10,
|
||||||
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||||
|
|||||||
@@ -24,17 +24,7 @@ FactoryBot.define do
|
|||||||
|
|
||||||
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
||||||
|
|
||||||
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
TagNameCompletion.for_tag!(tag)
|
||||||
.uniq(&:language_code).each do |locale|
|
|
||||||
next if tag.tag_names.exists?(language_code: locale.language_code,
|
|
||||||
primary_flg: true)
|
|
||||||
|
|
||||||
create(:tag_name,
|
|
||||||
tag:, language_code: locale.language_code,
|
|
||||||
script_code: locale.script_code,
|
|
||||||
name: TagName.generate_name(locale, tag, evaluator.primary_tag_name),
|
|
||||||
primary_flg: true, auto_generated: true)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do
|
|||||||
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
|
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'accepts English when multilingual support is enabled' do
|
it 'accepts Ainu when multilingual support is enabled' do
|
||||||
pending '日本語以外の Language 有効化は別課題で実装する'
|
expect { described_class.create!(code: 'ain', name: 'ain') }
|
||||||
expect { described_class.create!(code: 'en', name: 'English') }
|
|
||||||
.to change(described_class, :count).by(1)
|
.to change(described_class, :count).by(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Locale activation', type: :model do
|
||||||
|
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
|
||||||
|
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
|
||||||
|
let!(:first) { create(:tag, primary_name: 'activation_first') }
|
||||||
|
let!(:second) { create(:tag, primary_name: 'activation_second') }
|
||||||
|
|
||||||
|
def build_locale deprecated_at: nil
|
||||||
|
Locale.new(code: 'ain-Kana', language_code: language.code,
|
||||||
|
script_code: script.code, region_code: 392, variants: '',
|
||||||
|
name: 'ain-Kana', deprecated_at:)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_names
|
||||||
|
TagName.where(language_code: 'ain', primary_flg: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fail_second_generation!
|
||||||
|
allow(TagName).to receive(:generate_name).and_call_original
|
||||||
|
allow(TagName).to receive(:generate_name)
|
||||||
|
.with(kind_of(Locale), second, second.tag_name('ja'))
|
||||||
|
.and_raise('backfill failed')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rolls back a newly created locale and the preceding generated names' do
|
||||||
|
locale = build_locale
|
||||||
|
fail_second_generation!
|
||||||
|
|
||||||
|
expect { locale.save! }.to raise_error('backfill failed')
|
||||||
|
|
||||||
|
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
|
||||||
|
expect(generated_names).to be_empty
|
||||||
|
expect(first.reload.name('ja')).to eq('activation_first')
|
||||||
|
expect(second.reload.name('ja')).to eq('activation_second')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not backfill while the locale is inactive' do
|
||||||
|
build_locale(deprecated_at: Time.current).save!
|
||||||
|
|
||||||
|
expect(generated_names).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'backfills missing primary names when the locale becomes active' do
|
||||||
|
locale = build_locale(deprecated_at: Time.current)
|
||||||
|
locale.save!
|
||||||
|
|
||||||
|
locale.update!(deprecated_at: nil)
|
||||||
|
|
||||||
|
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
|
||||||
|
expect(generated_names).to all(have_attributes(
|
||||||
|
script_code: 'Kana', primary_flg: true, auto_generated: true))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rolls back activation and all generated names when backfill fails' do
|
||||||
|
locale = build_locale(deprecated_at: Time.current)
|
||||||
|
locale.save!
|
||||||
|
previous_deprecated_at = locale.reload.deprecated_at
|
||||||
|
fail_second_generation!
|
||||||
|
|
||||||
|
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
|
||||||
|
|
||||||
|
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
|
||||||
|
expect(generated_names).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves a human primary through deactivation and repeated activation' do
|
||||||
|
locale = build_locale
|
||||||
|
locale.save!
|
||||||
|
primary = generated_names.find_by!(tag: first)
|
||||||
|
primary.update!(name: 'human_confirmed', auto_generated: false)
|
||||||
|
snapshot = generated_names.order(:id).map(&:attributes)
|
||||||
|
|
||||||
|
2.times do
|
||||||
|
locale.update!(deprecated_at: Time.current)
|
||||||
|
locale.update!(deprecated_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
|
||||||
|
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Locale, type: :model do
|
RSpec.describe Locale, type: :model do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
def create_english_locale!
|
def create_ainu_locale!
|
||||||
prepare_english_reference!
|
prepare_ainu_reference!
|
||||||
described_class.create!(code: 'en', language_code: 'en',
|
described_class.create!(code: 'ain-Kana', language_code: 'ain',
|
||||||
script_code: 'Latn', region_code: 826,
|
script_code: 'Kana', region_code: 392,
|
||||||
variants: '', name: 'English')
|
variants: '', name: 'Ainu')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps the active Nipponese locale consistent with its identity tuple' do
|
it 'keeps the active Nipponese locale consistent with its identity tuple' do
|
||||||
@@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'generates a primary name for every existing tag using the name generator' do
|
it 'generates a primary name for every existing tag using the name generator' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
first = create(:tag, primary_name: 'first_existing_tag')
|
first = create(:tag, primary_name: 'first_existing_tag')
|
||||||
second = create(:tag, primary_name: 'second_existing_tag')
|
second = create(:tag, primary_name: 'second_existing_tag')
|
||||||
allow(TagName).to receive(:generate_name).and_call_original
|
allow(TagName).to receive(:generate_name).and_call_original
|
||||||
@@ -35,63 +34,60 @@ RSpec.describe Locale, type: :model do
|
|||||||
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
|
.with(kind_of(described_class), first, primary_tag_name_for(first, 'ja'))
|
||||||
.and_return('name_from_generator')
|
.and_return('name_from_generator')
|
||||||
|
|
||||||
locale = create_english_locale!
|
locale = create_ainu_locale!
|
||||||
|
|
||||||
expect(TagName).to have_received(:generate_name)
|
expect(TagName).to have_received(:generate_name)
|
||||||
.with(locale, first, primary_tag_name_for(first, 'ja'))
|
.with(locale, first, primary_tag_name_for(first, 'ja'))
|
||||||
expect(TagName).to have_received(:generate_name)
|
expect(TagName).to have_received(:generate_name)
|
||||||
.with(locale, second, primary_tag_name_for(second, 'ja'))
|
.with(locale, second, primary_tag_name_for(second, 'ja'))
|
||||||
expect(first.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
expect(first.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||||
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
|
.to have_attributes(name: 'name_from_generator', tag_id: first.id,
|
||||||
language_code: 'en', script_code: 'Latn',
|
language_code: 'ain', script_code: 'Kana',
|
||||||
primary_flg: true, auto_generated: true)
|
primary_flg: true, auto_generated: true)
|
||||||
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||||
.to have_attributes(tag_id: second.id, language_code: 'en',
|
.to have_attributes(tag_id: second.id, language_code: 'ain',
|
||||||
script_code: 'Latn', primary_flg: true,
|
script_code: 'Kana', primary_flg: true,
|
||||||
auto_generated: true)
|
auto_generated: true)
|
||||||
expect(TagName.where(language_code: 'en', primary_flg: true,
|
expect(TagName.where(language_code: 'ain', primary_flg: true,
|
||||||
tag_id: [first.id, second.id]).count).to eq(2)
|
tag_id: [first.id, second.id]).count).to eq(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not duplicate an existing primary name in the new language' do
|
it 'does not duplicate an existing primary name in the new language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_reference!
|
||||||
prepare_english_reference!
|
|
||||||
tag = create(:tag, primary_name: 'already_named')
|
tag = create(:tag, primary_name: 'already_named')
|
||||||
existing = create(:tag_name, name: 'existing_english', tag:,
|
existing = create(:tag_name, name: 'existing_ainu', tag:,
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
create_english_locale!
|
create_ainu_locale!
|
||||||
|
|
||||||
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true))
|
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true))
|
||||||
.to contain_exactly(existing)
|
.to contain_exactly(existing)
|
||||||
expect(existing.reload).to have_attributes(
|
expect(existing.reload).to have_attributes(
|
||||||
tag_id: tag.id, language_code: 'en', script_code: 'Latn',
|
tag_id: tag.id, language_code: 'ain', script_code: 'Kana',
|
||||||
primary_flg: true, auto_generated: false)
|
primary_flg: true, auto_generated: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a primary name when the language has only an alias' do
|
it 'creates a primary name when the language has only an alias' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_reference!
|
||||||
prepare_english_reference!
|
|
||||||
tag = create(:tag, primary_name: 'alias_only_tag')
|
tag = create(:tag, primary_name: 'alias_only_tag')
|
||||||
alias_name = create(:tag_name, :alias, name: 'alias_en',
|
alias_name = create(:tag_name, :alias, name: 'alias_en',
|
||||||
tag:,
|
tag:,
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
create_english_locale!
|
create_ainu_locale!
|
||||||
|
|
||||||
expect(alias_name.reload).to have_attributes(
|
expect(alias_name.reload).to have_attributes(
|
||||||
tag_id: tag.id,
|
tag_id: tag.id,
|
||||||
language_code: 'en', script_code: 'Latn', primary_flg: false)
|
language_code: 'ain', script_code: 'Kana', primary_flg: false)
|
||||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
generated = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||||
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
|
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'ain',
|
||||||
script_code: 'Latn', primary_flg: true,
|
script_code: 'Kana', primary_flg: true,
|
||||||
auto_generated: true)
|
auto_generated: true)
|
||||||
expect(TagName.where(tag_id: tag.id, language_code: 'en', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag_id: tag.id, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rolls back the locale and all generated names when backfill fails' do
|
it 'rolls back the locale and all generated names when backfill fails' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_reference!
|
||||||
prepare_english_reference!
|
|
||||||
first = create(:tag, primary_name: 'atomic_first')
|
first = create(:tag, primary_name: 'atomic_first')
|
||||||
second = create(:tag, primary_name: 'atomic_second')
|
second = create(:tag, primary_name: 'atomic_second')
|
||||||
allow(TagName).to receive(:generate_name).and_call_original
|
allow(TagName).to receive(:generate_name).and_call_original
|
||||||
@@ -99,31 +95,27 @@ RSpec.describe Locale, type: :model do
|
|||||||
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
|
.with(kind_of(described_class), second, primary_tag_name_for(second, 'ja'))
|
||||||
.and_raise('backfill failed')
|
.and_raise('backfill failed')
|
||||||
|
|
||||||
expect { create_english_locale! }.to raise_error('backfill failed')
|
expect { create_ainu_locale! }.to raise_error('backfill failed')
|
||||||
|
|
||||||
expect(described_class.exists?(code: 'en')).to be(false)
|
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
|
||||||
expect(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
|
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'accepts a stable locale code outside the historical enum values' do
|
it 'rejects a private locale code outside the supported locale set' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_reference!
|
||||||
prepare_english_reference!
|
|
||||||
|
|
||||||
locale = described_class.create!(
|
expect {
|
||||||
code: 'en-x-rspec', language_code: 'en',
|
described_class.create!(
|
||||||
script_code: 'Latn', region_code: 826,
|
code: 'ain-Kana-x-rspec', language_code: 'ain',
|
||||||
variants: 'x-rspec', name: 'English')
|
script_code: 'Kana', region_code: 392,
|
||||||
|
variants: 'x-rspec', name: 'Ainu')
|
||||||
expect(locale.reload).to have_attributes(
|
}.to raise_error(ArgumentError)
|
||||||
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
|
|
||||||
region_code: 826, variants: 'x-rspec')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'registers the future British English locale tuple' do
|
it 'registers the supported Ainu locale tuple' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
|
|
||||||
expect(locale).to have_attributes(code: 'en', language_code: 'en',
|
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
|
||||||
script_code: 'Latn', region_code: 826,
|
script_code: 'Kana', region_code: 392,
|
||||||
variants: '')
|
variants: '')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Script, type: :model do
|
RSpec.describe Script, type: :model do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'selects locales by script code, not language code' do
|
it 'selects locales by script code, not language code' do
|
||||||
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
|
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
|
||||||
@@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'selects Latin locales when multilingual support is enabled' do
|
it 'selects Kana locales when multilingual support is enabled' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
|
|
||||||
expect(described_class.find('Latn').locales).to contain_exactly(locale)
|
expect(described_class.find('Kana').locales).to contain_exactly(locale)
|
||||||
expect(described_class.find('Jpan').locales).not_to include(locale)
|
expect(described_class.find('Jpan').locales).not_to include(locale)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,38 +95,36 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'when only another language has the sanitised name' do
|
context 'when only another language has the sanitised name' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'keeps both names when sanitisation produces a cross-language match' do
|
it 'keeps both names when sanitisation produces a cross-language match' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag_name, name: 'foobar')
|
nipponese = create(:tag_name, name: 'foobar')
|
||||||
english = create(:tag_name, name: 'temporary',
|
ainu = create(:tag_name, name: 'temporary',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english.update_columns(name: 'foo_bar')
|
ainu.update_columns(name: 'foo_bar')
|
||||||
|
|
||||||
expect { described_class.apply! }.not_to change(TagName, :count)
|
expect { described_class.apply! }.not_to change(TagName, :count)
|
||||||
|
|
||||||
expect(english.reload).to have_attributes(name: 'foobar', language_code: 'en')
|
expect(ainu.reload).to have_attributes(name: 'foobar', language_code: 'ain')
|
||||||
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
|
expect(nipponese.reload).to have_attributes(name: 'foobar', language_code: 'ja')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renames without merging tags or deleting either language identity' do
|
it 'renames without merging tags or deleting either language identity' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag, primary_name: 'foobar')
|
nipponese = create(:tag, primary_name: 'foobar')
|
||||||
english_name = create(:tag_name, name: 'temporary',
|
ainu_name = create(:tag_name, name: 'temporary',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english = create(:tag, primary_tag_name: english_name)
|
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||||
english_name.update_columns(name: 'foo_bar')
|
ainu_name.update_columns(name: 'foo_bar')
|
||||||
|
|
||||||
described_class.apply!
|
described_class.apply!
|
||||||
|
|
||||||
expect(english_name.reload).to have_attributes(
|
expect(ainu_name.reload).to have_attributes(
|
||||||
name: 'foobar', language_code: 'en', tag_id: english.id)
|
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
|
||||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||||
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
|
name: 'foobar', language_code: 'ja', tag_id: nipponese.id)
|
||||||
expect(primary_tag_name_for(english.reload, 'en')).to eq(english_name)
|
expect(primary_tag_name_for(ainu.reload, 'ain')).to eq(ainu_name)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe TagName, type: :model do
|
RSpec.describe TagName, type: :model do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'allows the same name in different languages' do
|
it 'allows the same name in different languages' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
create(:tag_name, name: 'shared_name')
|
create(:tag_name, name: 'shared_name')
|
||||||
english = build(:tag_name, name: 'shared_name',
|
ainu = build(:tag_name, name: 'shared_name',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
expect(english).to be_valid
|
expect(ainu).to be_valid
|
||||||
expect { english.save! }.to change(described_class, :count).by(1)
|
expect { ainu.save! }.to change(described_class, :count).by(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects a duplicate name within the same language' do
|
it 'rejects a duplicate name within the same language' do
|
||||||
@@ -67,11 +66,10 @@ RSpec.describe TagName, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'completes an ordinary tag factory under future active languages' do
|
it 'completes an ordinary tag factory under future active languages' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tag = create(:tag, primary_name: 'future_factory')
|
tag = create(:tag, primary_name: 'future_factory')
|
||||||
|
|
||||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
|
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
|
||||||
.to contain_exactly(have_attributes(auto_generated: true))
|
.to contain_exactly(have_attributes(auto_generated: true))
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -114,46 +112,44 @@ RSpec.describe TagName, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the primary name of the alias language on the same tag' do
|
it 'resolves the primary name of the alias language on the same tag' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tag = create(:tag, primary_name: '日本語正本')
|
tag = create(:tag, primary_name: '日本語正本')
|
||||||
english_primary = primary_tag_name_for(tag, 'en')
|
ainu_primary = primary_tag_name_for(tag, 'ain')
|
||||||
english_primary.update!(name: 'english_primary', auto_generated: false)
|
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
|
||||||
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
representative_id = tag.tag_name('ja')&.id
|
representative_id = tag.tag_name('ja')&.id
|
||||||
|
|
||||||
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
|
||||||
language_code: english_alias.language_code,
|
language_code: ainu_alias.language_code,
|
||||||
primary_flg: true)
|
primary_flg: true)
|
||||||
|
|
||||||
expect(primary).to eq(english_primary)
|
expect(primary).to eq(ainu_primary)
|
||||||
expect(described_class.canonicalise(locale, [english_alias.name]))
|
expect(described_class.canonicalise(locale, [ainu_alias.name]))
|
||||||
.to eq([english_primary.name])
|
.to eq([ainu_primary.name])
|
||||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves only aliases in the requested language' do
|
it 'resolves only aliases in the requested language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||||
english_name = create(:tag_name, name: 'english_canonical',
|
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english = create(:tag, primary_tag_name: english_name)
|
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: nipponese)
|
tag: nipponese)
|
||||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: english,
|
tag: ainu,
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
expect(nipponese_alias.reload).to have_attributes(
|
expect(nipponese_alias.reload).to have_attributes(
|
||||||
tag_id: nipponese.id,
|
tag_id: nipponese.id,
|
||||||
primary_flg: false, language_code: 'ja')
|
primary_flg: false, language_code: 'ja')
|
||||||
expect(english_alias.reload).to have_attributes(
|
expect(ainu_alias.reload).to have_attributes(
|
||||||
tag_id: english.id,
|
tag_id: ainu.id,
|
||||||
primary_flg: false, language_code: 'en')
|
primary_flg: false, language_code: 'ain')
|
||||||
expect(described_class.canonicalise(locale, ['shared_alias']))
|
expect(described_class.canonicalise(locale, ['shared_alias']))
|
||||||
.to eq(['english_canonical'])
|
.to eq(['ainu_canonical'])
|
||||||
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
|
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
|
||||||
.to eq(['nipponese_canonical'])
|
.to eq(['nipponese_canonical'])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ RSpec.describe Tag, type: :model do
|
|||||||
it 'checks the requested language and treats an empty description as present' do
|
it 'checks the requested language and treats an empty description as present' do
|
||||||
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
TagDescription.create!(tag:, language_code: 'ja', body: '', version_no: 1)
|
||||||
expect(tag.has_description?(language_code: 'ja')).to be(true)
|
expect(tag.has_description?(language_code: 'ja')).to be(true)
|
||||||
expect(tag.has_description?(language_code: 'en')).to be(false)
|
expect(tag.has_description?(language_code: 'ain')).to be(false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false without a description' do
|
it 'returns false without a description' do
|
||||||
@@ -23,16 +23,16 @@ RSpec.describe Tag, type: :model do
|
|||||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||||
expect(loaded.has_description?(language_code: 'ja')).to be(true)
|
expect(loaded.has_description?(language_code: 'ja')).to be(true)
|
||||||
expect(loaded.has_description?(language_code: 'en')).to be(false)
|
expect(loaded.has_description?(language_code: 'ain')).to be(false)
|
||||||
end
|
end
|
||||||
expect(queries).to be_empty
|
expect(queries).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'keeps language selection generic without enabling another Language record' do
|
it 'keeps language selection generic without enabling another Language record' do
|
||||||
tag.association(:tag_descriptions).target = [
|
tag.association(:tag_descriptions).target = [
|
||||||
TagDescription.new(tag:, language_code: 'en', body: '', version_no: 1)]
|
TagDescription.new(tag:, language_code: 'ain', body: '', version_no: 1)]
|
||||||
tag.association(:tag_descriptions).loaded!
|
tag.association(:tag_descriptions).loaded!
|
||||||
expect(tag.has_description?(language_code: 'en')).to be(true)
|
expect(tag.has_description?(language_code: 'ain')).to be(true)
|
||||||
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
expect(tag.has_description?(language_code: 'ja')).to be(false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -61,33 +61,32 @@ RSpec.describe Tag, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
describe '.normalise_tags!' do
|
describe '.normalise_tags!' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'canonicalises aliases in the supplied language only' do
|
it 'canonicalises aliases in the supplied language only' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_reference!
|
||||||
prepare_english_reference!
|
|
||||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||||
english_name = create(:tag_name, name: 'english_canonical',
|
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english = create(:tag, primary_tag_name: english_name)
|
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||||
primary_tag_name_for(english, 'ja').update!(
|
primary_tag_name_for(ainu, 'ja').update!(
|
||||||
name: 'english_in_nipponese', auto_generated: false)
|
name: 'ainu_in_nipponese', auto_generated: false)
|
||||||
english_locale = locale
|
ainu_locale = locale
|
||||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: nipponese)
|
tag: nipponese)
|
||||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: english,
|
tag: ainu,
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
expect(nipponese_alias.reload).to have_attributes(
|
expect(nipponese_alias.reload).to have_attributes(
|
||||||
tag_id: nipponese.id,
|
tag_id: nipponese.id,
|
||||||
primary_flg: false, language_code: 'ja')
|
primary_flg: false, language_code: 'ja')
|
||||||
expect(english_alias.reload).to have_attributes(
|
expect(ainu_alias.reload).to have_attributes(
|
||||||
tag_id: english.id,
|
tag_id: ainu.id,
|
||||||
primary_flg: false, language_code: 'en')
|
primary_flg: false, language_code: 'ain')
|
||||||
expect(described_class.normalise_tags!(
|
expect(described_class.normalise_tags!(
|
||||||
english_locale, ['shared_alias'],
|
ainu_locale, ['shared_alias'],
|
||||||
with_tagme: false, with_no_deerjikist: false)).to eq([english])
|
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
|
||||||
expect(described_class.normalise_tags!(
|
expect(described_class.normalise_tags!(
|
||||||
Locale.nipponese, ['shared_alias'],
|
Locale.nipponese, ['shared_alias'],
|
||||||
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
|
with_tagme: false, with_no_deerjikist: false)).to eq([nipponese])
|
||||||
@@ -253,129 +252,120 @@ RSpec.describe Tag, type: :model do
|
|||||||
|
|
||||||
describe '.find_or_create_by_tag_name!' do
|
describe '.find_or_create_by_tag_name!' do
|
||||||
context 'with an explicit locale' do
|
context 'with an explicit locale' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'creates the representative name with locale attributes and ownership' do
|
it 'creates the representative name with locale attributes and ownership' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tag = described_class.find_or_create_by_tag_name!(
|
tag = described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'english_name', category: :character)
|
locale, 'ainu_name', category: :character)
|
||||||
|
|
||||||
expect(primary_tag_name_for(tag, 'en')).to have_attributes(
|
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
|
||||||
language_code: 'en', script_code: 'Latn',
|
language_code: 'ain', script_code: 'Kana',
|
||||||
primary_flg: true, tag_id: tag.id)
|
primary_flg: true, tag_id: tag.id)
|
||||||
expect(tag.category).to eq('character')
|
expect(tag.category).to eq('character')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'uses the English primary as the source for a generated Nipponese name' do
|
it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
allow(TagName).to receive(:generate_name).and_return('generated_nipponese')
|
allow(TagName).to receive(:generate_name).and_call_original
|
||||||
|
|
||||||
tag = described_class.find_or_create_by_tag_name!(
|
tag = described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'english_origin', category: :general)
|
locale, 'ainu_origin', category: :general)
|
||||||
|
|
||||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true))
|
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true))
|
||||||
.to contain_exactly(have_attributes(
|
.to contain_exactly(have_attributes(
|
||||||
name: 'english_origin', auto_generated: false))
|
name: 'ainu_origin', auto_generated: false))
|
||||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||||
.to contain_exactly(have_attributes(
|
.to contain_exactly(have_attributes(
|
||||||
name: 'generated_nipponese', auto_generated: true))
|
name: "Tag_##{ tag.id }", auto_generated: true))
|
||||||
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
|
|
||||||
expect(TagName).to have_received(:generate_name)
|
expect(TagName).to have_received(:generate_name)
|
||||||
.with(Locale.nipponese, tag, source_tag_name)
|
.with(Locale.nipponese, tag, nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'gives a new tag one primary name for every active language' do
|
it 'gives a new tag one primary name for every active language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tag = described_class.find_or_create_by_tag_name!(
|
tag = described_class.find_or_create_by_tag_name!(
|
||||||
Locale.nipponese, 'new_multilingual_tag', category: :general)
|
Locale.nipponese, 'new_multilingual_tag', category: :general)
|
||||||
|
|
||||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates one primary per language when multiple locales use English' do
|
it 'creates one primary per language when multiple locales use Ainu' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
described_class.find_or_create_by_tag_name!(
|
described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'before_second_english_locale', category: :general)
|
locale, 'before_second_ainu_locale', category: :general)
|
||||||
Locale.create!(code: 'en-x-rspec', language_code: 'en',
|
|
||||||
script_code: 'Latn', region_code: 826,
|
|
||||||
variants: 'x-rspec', name: 'English')
|
|
||||||
|
|
||||||
tag = described_class.find_or_create_by_tag_name!(
|
tag = described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'one_english_primary', category: :general)
|
locale, 'one_ainu_primary', category: :general)
|
||||||
|
TagNameCompletion.for_tag!(tag, locales: [locale, locale.dup, Locale.nipponese])
|
||||||
|
|
||||||
expect(TagName.where(tag:, language_code: 'en', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true).count).to eq(1)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rolls back a new tag and requested name if name generation fails' do
|
it 'rolls back a new tag and requested name if name generation fails' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
original_tag_count = described_class.count
|
original_tag_count = described_class.count
|
||||||
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
||||||
|
|
||||||
expect {
|
expect {
|
||||||
described_class.find_or_create_by_tag_name!(
|
described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'atomic_english_name', category: :general)
|
locale, 'atomic_ainu_name', category: :general)
|
||||||
}.to raise_error('generation failed')
|
}.to raise_error('generation failed')
|
||||||
|
|
||||||
expect(described_class.count).to eq(original_tag_count)
|
expect(described_class.count).to eq(original_tag_count)
|
||||||
expect(TagName.where(language_code: 'en', name: 'atomic_english_name')).to be_empty
|
expect(TagName.where(language_code: 'ain', name: 'atomic_ainu_name')).to be_empty
|
||||||
expect(described_class.joins(:tag_names)
|
expect(described_class.joins(:tag_names)
|
||||||
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it 'creates separate tag identities for the same name in different languages' do
|
it 'creates separate tag identities for the same name in different languages' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = described_class.find_or_create_by_tag_name!(
|
nipponese = described_class.find_or_create_by_tag_name!(
|
||||||
Locale.nipponese, 'same_name', category: :general)
|
Locale.nipponese, 'same_name', category: :general)
|
||||||
english = described_class.find_or_create_by_tag_name!(
|
primary_tag_name_for(nipponese, 'ain').update!(
|
||||||
|
name: 'other_ainu_name', auto_generated: false)
|
||||||
|
ainu = described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'same_name', category: :character)
|
locale, 'same_name', category: :character)
|
||||||
|
|
||||||
expect(english).not_to eq(nipponese)
|
expect(ainu).not_to eq(nipponese)
|
||||||
expect(primary_tag_name_for(english, 'en')).to have_attributes(
|
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
|
||||||
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
|
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
|
||||||
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
|
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
|
||||||
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
|
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
|
||||||
expect(described_class.find_or_create_by_tag_name!(
|
expect(described_class.find_or_create_by_tag_name!(
|
||||||
locale, 'same_name', category: :general)).to eq(english)
|
locale, 'same_name', category: :general)).to eq(ainu)
|
||||||
expect(described_class.find_or_create_by_tag_name!(
|
expect(described_class.find_or_create_by_tag_name!(
|
||||||
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
|
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preserves the V1 representative when looking up another primary language' do
|
it 'preserves the V1 representative when looking up another primary language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tag = create(:tag, primary_name: '日本語代表名')
|
tag = create(:tag, primary_name: '日本語代表名')
|
||||||
representative_id = tag.tag_name('ja')&.id
|
representative_id = tag.tag_name('ja')&.id
|
||||||
english = primary_tag_name_for(tag, 'en')
|
ainu = primary_tag_name_for(tag, 'ain')
|
||||||
english.update!(name: 'english_primary', auto_generated: false)
|
ainu.update!(name: 'ainu_primary', auto_generated: false)
|
||||||
|
|
||||||
found = described_class.find_or_create_by_tag_name!(
|
found = described_class.find_or_create_by_tag_name!(
|
||||||
locale, english.name, category: :general)
|
locale, ainu.name, category: :general)
|
||||||
|
|
||||||
expect(found).to eq(tag)
|
expect(found).to eq(tag)
|
||||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||||
expect(english.reload.tag_id).to eq(tag.id)
|
expect(ainu.reload.tag_id).to eq(tag.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'normalises names using the supplied locale' do
|
it 'normalises names using the supplied locale' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
tags = described_class.normalise_tags!(
|
tags = described_class.normalise_tags!(
|
||||||
locale, ['character:normalised_english'],
|
locale, ['character:normalised_ainu'],
|
||||||
with_tagme: false, with_no_deerjikist: false)
|
with_tagme: false, with_no_deerjikist: false)
|
||||||
|
|
||||||
expect(tags.length).to eq(1)
|
expect(tags.length).to eq(1)
|
||||||
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
|
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
|
||||||
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
|
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
|
||||||
primary_flg: true, tag_id: tags.first.id)
|
primary_flg: true, tag_id: tags.first.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -444,26 +434,25 @@ RSpec.describe Tag, type: :model do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'with names in multiple languages' do
|
context 'with names in multiple languages' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'keeps the target primary and moves source names as aliases' do
|
it 'keeps the target primary and moves source names as aliases' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_primary = primary_tag_name_for(target_tag, 'en')
|
ainu_primary = primary_tag_name_for(target_tag, 'ain')
|
||||||
source_primary = primary_tag_name_for(source_tag, 'en')
|
source_primary = primary_tag_name_for(source_tag, 'ain')
|
||||||
source_alias = create(:tag_name, :alias, tag: source_tag,
|
source_alias = create(:tag_name, :alias, tag: source_tag,
|
||||||
name: 'source_english_alias', language_code: 'en', script_code: 'Latn')
|
name: 'source_ainu_alias', language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
expect([english_primary, source_primary]).to all(have_attributes(
|
expect([ainu_primary, source_primary]).to all(have_attributes(
|
||||||
primary_flg: true, auto_generated: true))
|
primary_flg: true, auto_generated: true))
|
||||||
|
|
||||||
described_class.merge_tags!(target_tag, [source_tag])
|
described_class.merge_tags!(target_tag, [source_tag])
|
||||||
|
|
||||||
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
|
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
|
||||||
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
|
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
|
||||||
.to contain_exactly(english_primary)
|
.to contain_exactly(ainu_primary)
|
||||||
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
|
expect([source_primary.reload, source_alias.reload]).to all(have_attributes(
|
||||||
tag_id: target_tag.id, language_code: 'en', primary_flg: false))
|
tag_id: target_tag.id, language_code: 'ain', primary_flg: false))
|
||||||
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
|
expect(TagName.where(tag: target_tag, language_code: 'ja', primary_flg: true))
|
||||||
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
|
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
|
||||||
expect(source_tag_name.reload).to have_attributes(
|
expect(source_tag_name.reload).to have_attributes(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'API locale resolution', type: :request do
|
||||||
|
let!(:tag) { create(:tag, primary_name: '日本語正本') }
|
||||||
|
|
||||||
|
it 'defaults an omitted locale to Japanese' do
|
||||||
|
get "/tags/#{ tag.id }"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('name')).to eq('日本語正本')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'accepts an explicitly requested active Japanese locale' do
|
||||||
|
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json.fetch('name')).to eq('日本語正本')
|
||||||
|
end
|
||||||
|
|
||||||
|
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
|
||||||
|
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
|
||||||
|
get "/tags/#{ tag.id }", params: { locale: code }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
expect(response.body).not_to include('日本語正本')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects a supported locale that has not been registered' do
|
||||||
|
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects an inactive locale' do
|
||||||
|
Locale.nipponese.update!(deprecated_at: Time.current)
|
||||||
|
|
||||||
|
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not write posts or tag names under an invalid locale' do
|
||||||
|
sign_in_as(create_member_user!)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
post '/posts', params: {
|
||||||
|
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
|
||||||
|
tags: 'unwritten_tag' }
|
||||||
|
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe 'Locale propagation on write paths', type: :request do
|
RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
let(:member) { create(:user, :member) }
|
let(:member) { create(:user, :member) }
|
||||||
|
|
||||||
@@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
['en', nil].each do |requested_locale|
|
['ain-Kana', nil].each do |requested_locale|
|
||||||
context "with locale #{ requested_locale.inspect }" do
|
context "with locale #{ requested_locale.inspect }" do
|
||||||
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
||||||
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
||||||
|
|
||||||
it 'creates post tags in the requested language or Nipponese fallback' do
|
it 'creates post tags in the requested language or Nipponese fallback' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
post '/posts', params: locale_params.merge(
|
post '/posts', params: locale_params.merge(
|
||||||
title: 'Locale post', url: 'https://example.com/locale-post',
|
title: 'Locale post', url: 'https://example.com/locale-post',
|
||||||
@@ -72,7 +71,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'updates post tags in the requested language or Nipponese fallback' do
|
it 'updates post tags in the requested language or Nipponese fallback' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
record = create(:post)
|
record = create(:post)
|
||||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||||
@@ -87,7 +85,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'passes the resolved locale to bulk creation' do
|
it 'passes the resolved locale to bulk creation' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
# Worker propagation is exercised in post_bulk_creator_spec.
|
# Worker propagation is exercised in post_bulk_creator_spec.
|
||||||
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
||||||
@@ -102,7 +99,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates materials with tags in the resolved locale' do
|
it 'creates materials with tags in the resolved locale' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
post '/materials', params: locale_params.merge(
|
post '/materials', params: locale_params.merge(
|
||||||
tag: 'locale_material', url: 'https://example.com/material')
|
tag: 'locale_material', url: 'https://example.com/material')
|
||||||
@@ -113,7 +109,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'updates materials with tags in the resolved locale' do
|
it 'updates materials with tags in the resolved locale' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
tag = create(:tag, category: :material)
|
tag = create(:tag, category: :material)
|
||||||
material = Material.create!(tag:, url: 'https://example.com/material')
|
material = Material.create!(tag:, url: 'https://example.com/material')
|
||||||
@@ -127,7 +122,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'links Nico external tags to internal tags in the resolved locale' do
|
it 'links Nico external tags to internal tags in the resolved locale' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
external = create(:external_tag)
|
external = create(:external_tag)
|
||||||
NicoTagVersionRecorder.record!(
|
NicoTagVersionRecorder.record!(
|
||||||
@@ -141,7 +135,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'creates parent tags in the resolved locale' do
|
it 'creates parent tags in the resolved locale' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
|
||||||
locale if requested_locale
|
locale if requested_locale
|
||||||
tag = create(:tag)
|
tag = create(:tag)
|
||||||
|
|
||||||
@@ -157,10 +150,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'with existing names in both languages' do
|
context 'with existing names in both languages' do
|
||||||
let(:english_name) do
|
let(:ainu_name) do
|
||||||
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
|
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
|
||||||
end
|
end
|
||||||
let(:english_tag) { create(:tag, primary_tag_name: english_name, category: :material) }
|
let(:ainu_tag) { create(:tag, primary_tag_name: ainu_name, category: :material) }
|
||||||
let(:nipponese_tag) do
|
let(:nipponese_tag) do
|
||||||
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
|
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
|
||||||
# Isolate lookup from the separately tested uniqueness validation.
|
# Isolate lookup from the separately tested uniqueness validation.
|
||||||
@@ -168,173 +161,163 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'plans and creates the post using the English identity and category' do
|
it 'plans and creates the post using the Ainu identity and category' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_tag
|
ainu_tag
|
||||||
nipponese_tag
|
nipponese_tag
|
||||||
post '/posts', params: {
|
post '/posts', params: {
|
||||||
locale: 'en', title: 'English identity',
|
locale: 'ain-Kana', title: 'Ainu identity',
|
||||||
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
|
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:created), response.body
|
expect(response).to have_http_status(:created), response.body
|
||||||
record = Post.find(json.fetch('id'))
|
record = Post.find(json.fetch('id'))
|
||||||
expect(record.tags).to include(english_tag)
|
expect(record.tags).to include(ainu_tag)
|
||||||
expect(record.tags).not_to include(nipponese_tag)
|
expect(record.tags).not_to include(nipponese_tag)
|
||||||
expect(english_tag.reload.category).to eq('material')
|
expect(ainu_tag.reload.category).to eq('material')
|
||||||
expect(nipponese_tag.reload.category).to eq('general')
|
expect(nipponese_tag.reload.category).to eq('general')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'updates the post using the English identity' do
|
it 'updates the post using the Ainu identity' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_tag
|
ainu_tag
|
||||||
nipponese_tag
|
nipponese_tag
|
||||||
record = create(:post)
|
record = create(:post)
|
||||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||||
|
|
||||||
put "/posts/#{ record.id }", params: {
|
put "/posts/#{ record.id }", params: {
|
||||||
locale: 'en', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
locale: 'ain-Kana', title: record.title, tags: 'shared_name', parent_post_ids: '',
|
||||||
base_version_no: record.reload.version_no }
|
base_version_no: record.reload.version_no }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(record.reload.tags).to include(english_tag)
|
expect(record.reload.tags).to include(ainu_tag)
|
||||||
expect(record.tags).not_to include(nipponese_tag)
|
expect(record.tags).not_to include(nipponese_tag)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the English material tag' do
|
it 'resolves the Ainu material tag' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_tag
|
ainu_tag
|
||||||
nipponese_tag
|
nipponese_tag
|
||||||
post '/materials', params: {
|
post '/materials', params: {
|
||||||
locale: 'en', tag: 'shared_name', url: 'https://example.com/english-material' }
|
locale: 'ain-Kana', tag: 'shared_name', url: 'https://example.com/ainu-material' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:created), response.body
|
expect(response).to have_http_status(:created), response.body
|
||||||
expect(Material.find(json.fetch('id')).tag).to eq(english_tag)
|
expect(Material.find(json.fetch('id')).tag).to eq(ainu_tag)
|
||||||
expect(Material.where(tag: nipponese_tag)).to be_empty
|
expect(Material.where(tag: nipponese_tag)).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the English parent tag' do
|
it 'resolves the Ainu parent tag' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_tag
|
ainu_tag
|
||||||
nipponese_tag
|
nipponese_tag
|
||||||
child = create(:tag)
|
child = create(:tag)
|
||||||
|
|
||||||
put "/tags/#{ child.id }", params: {
|
put "/tags/#{ child.id }", params: {
|
||||||
locale: 'en', name: primary_tag_name_for(child, 'ja').name,
|
locale: 'ain-Kana', name: primary_tag_name_for(child, 'ja').name,
|
||||||
category: child.category, deprecated: false,
|
category: child.category, deprecated: false,
|
||||||
aliases: '', parent_tags: 'shared_name' }
|
aliases: '', parent_tags: 'shared_name' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([english_tag.id])
|
expect(TagImplication.where(tag: child).pluck(:parent_tag_id)).to eq([ainu_tag.id])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves the English internal tag for a Nico link' do
|
it 'resolves the Ainu internal tag for a Nico link' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_tag
|
ainu_tag
|
||||||
nipponese_tag
|
nipponese_tag
|
||||||
external = create(:external_tag)
|
external = create(:external_tag)
|
||||||
NicoTagVersionRecorder.record!(
|
NicoTagVersionRecorder.record!(
|
||||||
external_tag: external, event_type: :create, created_by_user: member)
|
external_tag: external, event_type: :create, created_by_user: member)
|
||||||
|
|
||||||
put "/tags/nico/#{ external.id }", params: { locale: 'en', tags: 'shared_name' }
|
put "/tags/nico/#{ external.id }", params: { locale: 'ain-Kana', tags: 'shared_name' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(external.reload.linked_tags).to contain_exactly(english_tag)
|
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when renaming or updating aliases in English' do
|
context 'when renaming or updating aliases in Ainu' do
|
||||||
let(:english_name) do
|
let(:ainu_name) do
|
||||||
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
|
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
|
||||||
end
|
end
|
||||||
let(:tag) { create(:tag, primary_tag_name: english_name) }
|
let(:tag) { create(:tag, primary_tag_name: ainu_name) }
|
||||||
|
|
||||||
it 'marks a human-renamed generated primary as no longer auto-generated' do
|
it 'marks a human-renamed generated primary as no longer auto-generated' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
generated_tag = create(:tag, primary_name: 'rename_generated_source')
|
generated_tag = create(:tag, primary_name: 'rename_generated_source')
|
||||||
generated_name = primary_tag_name_for(generated_tag, 'en')
|
generated_name = primary_tag_name_for(generated_tag, 'ain')
|
||||||
expect(generated_name.auto_generated).to be(true)
|
expect(generated_name.auto_generated).to be(true)
|
||||||
|
|
||||||
patch "/tags/#{ generated_tag.id }", params: {
|
patch "/tags/#{ generated_tag.id }", params: {
|
||||||
locale: 'en', name: 'human_english_name' }
|
locale: 'ain-Kana', name: 'human_ainu_name' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(generated_name.reload).to have_attributes(
|
expect(generated_name.reload).to have_attributes(
|
||||||
name: 'human_english_name', auto_generated: false)
|
name: 'human_ainu_name', auto_generated: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not steal another tag primary for an alias' do
|
it 'does not steal another tag primary for an alias' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
other_tag = create(:tag, primary_name: 'other_nipponese')
|
other_tag = create(:tag, primary_name: 'other_nipponese')
|
||||||
other_primary = primary_tag_name_for(other_tag, 'en')
|
other_primary = primary_tag_name_for(other_tag, 'ain')
|
||||||
other_primary.update!(name: 'other_english_primary', auto_generated: false)
|
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
|
||||||
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
|
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
primary_name_before = primary_tag_name_for(tag, 'en').name
|
primary_name_before = primary_tag_name_for(tag, 'ain').name
|
||||||
aliases_before =
|
aliases_before =
|
||||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||||
.order(:name).pluck(:name)
|
.order(:name).pluck(:name)
|
||||||
category_before = tag.category
|
category_before = tag.category
|
||||||
|
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
locale: 'en', name: 'renamed_before_alias_collision',
|
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
|
||||||
category: 'character', deprecated: false,
|
category: 'character', deprecated: false,
|
||||||
aliases: 'other_english_primary', parent_tags: '' }
|
aliases: 'other_ainu_primary', parent_tags: '' }
|
||||||
|
|
||||||
aggregate_failures do
|
aggregate_failures do
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(other_primary.reload).to have_attributes(
|
expect(other_primary.reload).to have_attributes(
|
||||||
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
|
tag_id: other_tag.id, language_code: 'ain', primary_flg: true)
|
||||||
expect(TagName.where(tag: other_tag, language_code: 'en', primary_flg: true))
|
expect(TagName.where(tag: other_tag, language_code: 'ain', primary_flg: true))
|
||||||
.to contain_exactly(other_primary)
|
.to contain_exactly(other_primary)
|
||||||
expect(primary_tag_name_for(tag.reload, 'en').name).to eq(primary_name_before)
|
expect(primary_tag_name_for(tag.reload, 'ain').name).to eq(primary_name_before)
|
||||||
expect(tag.category).to eq(category_before)
|
expect(tag.category).to eq(category_before)
|
||||||
aliases_after =
|
aliases_after =
|
||||||
tag.tag_names.where(language_code: 'en', primary_flg: false)
|
tag.tag_names.where(language_code: 'ain', primary_flg: false)
|
||||||
.order(:name).pluck(:name)
|
.order(:name).pluck(:name)
|
||||||
expect(aliases_after).to eq(aliases_before)
|
expect(aliases_after).to eq(aliases_before)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'allows a rename to a name already used in Nipponese' do
|
it 'allows a rename to a name already used in Nipponese' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag, primary_name: 'rename_target')
|
nipponese = create(:tag, primary_name: 'rename_target')
|
||||||
|
|
||||||
patch "/tags/#{ tag.id }", params: { locale: 'en', name: 'rename_target' }
|
patch "/tags/#{ tag.id }", params: { locale: 'ain-Kana', name: 'rename_target' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
|
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
|
||||||
name: 'rename_target', language_code: 'en', tag_id: tag.id)
|
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
|
||||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||||
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates aliases in the requested language' do
|
it 'creates aliases in the requested language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
locale: 'en', name: english_name.name,
|
locale: 'ain-Kana', name: ainu_name.name,
|
||||||
category: tag.category, deprecated: false,
|
category: tag.category, deprecated: false,
|
||||||
aliases: 'english_alias', parent_tags: '' }
|
aliases: 'ainu_alias', parent_tags: '' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok), response.body
|
expect(response).to have_http_status(:ok), response.body
|
||||||
expect(TagName.find_by!(language_code: 'en', name: 'english_alias'))
|
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
|
||||||
.to have_attributes(
|
.to have_attributes(
|
||||||
script_code: 'Latn',
|
script_code: 'Kana',
|
||||||
tag_id: tag.id, primary_flg: false, language_code: 'en')
|
tag_id: tag.id, primary_flg: false, language_code: 'ain')
|
||||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||||
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
|
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not take an alias from another language' do
|
it 'does not take an alias from another language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
||||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
@@ -344,7 +327,7 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
primary_flg: false, language_code: 'ja')
|
primary_flg: false, language_code: 'ja')
|
||||||
|
|
||||||
put "/tags/#{ tag.id }", params: {
|
put "/tags/#{ tag.id }", params: {
|
||||||
locale: 'en', name: english_name.name,
|
locale: 'ain-Kana', name: ainu_name.name,
|
||||||
category: tag.category, deprecated: false,
|
category: tag.category, deprecated: false,
|
||||||
aliases: 'shared_alias', parent_tags: '' }
|
aliases: 'shared_alias', parent_tags: '' }
|
||||||
|
|
||||||
@@ -352,12 +335,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
|||||||
expect(nipponese_alias.reload).to have_attributes(
|
expect(nipponese_alias.reload).to have_attributes(
|
||||||
tag_id: nipponese.id,
|
tag_id: nipponese.id,
|
||||||
primary_flg: false, language_code: 'ja')
|
primary_flg: false, language_code: 'ja')
|
||||||
expect(TagName.find_by!(language_code: 'en', name: 'shared_alias'))
|
expect(TagName.find_by!(language_code: 'ain', name: 'shared_alias'))
|
||||||
.to have_attributes(
|
.to have_attributes(
|
||||||
tag_id: tag.id, script_code: 'Latn',
|
tag_id: tag.id, script_code: 'Kana',
|
||||||
primary_flg: false, language_code: 'en')
|
primary_flg: false, language_code: 'ain')
|
||||||
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
|
expect(TagName.where(name: 'shared_alias').pluck(:language_code, :tag_id))
|
||||||
.to contain_exactly(['ja', nipponese.id], ['en', tag.id])
|
.to contain_exactly(['ja', nipponese.id], ['ain', tag.id])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
|
|
||||||
it 'uses selected-language names in native collection and nested placements' do
|
it 'uses selected-language names in native collection and nested placements' do
|
||||||
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
|
["/tag_groups/#{ group.id }", '/tag_groups'].each do |path|
|
||||||
get path, params: { locale: 'ain' }
|
get path, params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
representation = json.is_a?(Array) ? json.sole : json
|
representation = json.is_a?(Array) ? json.sole : json
|
||||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
|
expect(representation.fetch('members').sole.fetch('tag').fetch('name'))
|
||||||
@@ -37,14 +37,14 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
it 'uses snapshot names recursively for history and current names for the latest view' do
|
it 'uses snapshot names recursively for history and current names for the latest view' do
|
||||||
version = group.versions.sole
|
version = group.versions.sole
|
||||||
ainu_name.update!(name: 'renamed_ainu')
|
ainu_name.update!(name: 'renamed_ainu')
|
||||||
get "/wiki/#{ group.id }", params: { locale: 'ain' }
|
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body')).to include('renamed_ainu')
|
expect(json.fetch('body')).to include('renamed_ainu')
|
||||||
get "/wiki/#{ group.id }", params: { locale: 'ain', version: version.id }
|
get "/wiki/#{ group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body').scan('ainu_member').length).to eq(2)
|
expect(json.fetch('body').scan('ainu_member').length).to eq(2)
|
||||||
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
|
expect(json.fetch('body')).not_to include('renamed_ainu', 'japanese_member')
|
||||||
get "/wiki/#{ group.id }/diff", params: { locale: 'ain', from: version.id }
|
get "/wiki/#{ group.id }/diff", params: { locale: 'ain-Kana', from: version.id }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
expect(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
||||||
end
|
end
|
||||||
@@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
||||||
it "round trips #{ prefix } through create, lookup, search and update" do
|
it "round trips #{ prefix } through create, lookup, search and update" do
|
||||||
post '/wiki', params: {
|
post '/wiki', params: {
|
||||||
locale: 'ain', title: "#{ prefix }parent", body: '- tag_group:locale group' },
|
locale: 'ain-Kana', title: "#{ prefix }parent", body: '- tag_group:locale group' },
|
||||||
headers: headers
|
headers: headers
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
id = json.fetch('id')
|
id = json.fetch('id')
|
||||||
expect(json.fetch('body')).to include('タググループ:locale group')
|
expect(json.fetch('body')).to include('タググループ:locale group')
|
||||||
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
|
path = "/wiki/title/#{ CGI.escapeURIComponent("#{ prefix }parent") }"
|
||||||
get path, params: { locale: 'ain' }
|
get path, params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('id')).to eq(id)
|
expect(json.fetch('id')).to eq(id)
|
||||||
get "#{ path }/exists", params: { locale: 'ain' }
|
get "#{ path }/exists", params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
get '/wiki/search', params: { locale: 'ain', title: "#{ prefix }parent" }
|
get '/wiki/search', params: { locale: 'ain-Kana', title: "#{ prefix }parent" }
|
||||||
expect(json.map { _1.fetch('id') }).to eq([id])
|
expect(json.map { _1.fetch('id') }).to eq([id])
|
||||||
put "/wiki/#{ id }", params: {
|
put "/wiki/#{ id }", params: {
|
||||||
locale: 'ain', title: "#{ prefix }renamed", body: '- ainu_member' }, headers: headers
|
locale: 'ain-Kana', title: "#{ prefix }renamed", body: '- ainu_member' },
|
||||||
|
headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
get "/tag_groups/#{ id }", params: { locale: 'ain' }
|
get "/tag_groups/#{ id }", params: { locale: 'ain-Kana' }
|
||||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
||||||
before_state = group_state
|
before_state = group_state
|
||||||
put "/wiki/#{ group.id }", params: {
|
put "/wiki/#{ group.id }", params: {
|
||||||
locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' },
|
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||||
headers: headers
|
headers: headers
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(group_state).to eq(before_state)
|
expect(group_state).to eq(before_state)
|
||||||
@@ -89,15 +90,15 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
|
|
||||||
it 'passes locale into native validation and facade compatibility checks' do
|
it 'passes locale into native validation and facade compatibility checks' do
|
||||||
post '/tag_groups', params: {
|
post '/tag_groups', params: {
|
||||||
locale: 'ain', name: 'native', description: nil, members:, placements: },
|
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
|
||||||
headers:, as: :json
|
headers:, as: :json
|
||||||
expect(response).to have_http_status(:created)
|
expect(response).to have_http_status(:created)
|
||||||
id = json.fetch('id')
|
id = json.fetch('id')
|
||||||
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
expect(json.fetch('members').sole.fetch('tag').fetch('name')).to eq('ainu_member')
|
||||||
get "/wiki/#{ id }", params: { locale: 'ain' }
|
get "/wiki/#{ id }", params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
body = json.fetch('body')
|
body = json.fetch('body')
|
||||||
put "/wiki/#{ id }", params: { locale: 'ain', title: 'tag_group:native', body: },
|
put "/wiki/#{ id }", params: { locale: 'ain-Kana', title: 'tag_group:native', body: },
|
||||||
headers: headers
|
headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(TagGroup.find(id).version_no).to eq(1)
|
expect(TagGroup.find(id).version_no).to eq(1)
|
||||||
@@ -115,7 +116,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
|||||||
representation = TagGroupRepr.historical(ainu_locale, version)
|
representation = TagGroupRepr.historical(ainu_locale, version)
|
||||||
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
|
expect(representation.fetch('members').sole.fetch('tag').fetch('name')).to be_nil
|
||||||
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
|
expect(representation.fetch('placements').sole.fetch('tag').fetch('name')).to be_nil
|
||||||
get "/wiki/#{ old_group.id }", params: { locale: 'ain', version: version.id }
|
get "/wiki/#{ old_group.id }", params: { locale: 'ain-Kana', version: version.id }
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(response.body).not_to include('historical_japanese', 'current_ainu')
|
expect(response.body).not_to include('historical_japanese', 'current_ainu')
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
|
|
||||||
['/wiki', '/wiki/search'].each do |path|
|
['/wiki', '/wiki/search'].each do |path|
|
||||||
it "filters #{ path } by the selected language" do
|
it "filters #{ path } by the selected language" do
|
||||||
get path, params: { locale: 'ain' }
|
get path, params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.map { _1.fetch('id') }).to eq([ainu.id])
|
expect(json.map { _1.fetch('id') }).to eq([ainu.id])
|
||||||
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
|
expect(json.sole).to include('title' => 'ainu_title', 'body' => 'Ainu body')
|
||||||
|
|
||||||
get path, params: { locale: 'ain', title: 'japanese' }
|
get path, params: { locale: 'ain-Kana', title: 'japanese' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to eq([])
|
expect(json).to eq([])
|
||||||
end
|
end
|
||||||
@@ -39,17 +39,17 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
|
|
||||||
it 'resolves titles and aliases only in the selected language in both APIs' do
|
it 'resolves titles and aliases only in the selected language in both APIs' do
|
||||||
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
|
['/wiki/title/japanese_title', '/tags/name/japanese_title/description'].each do |path|
|
||||||
get path, params: { locale: 'ain' }
|
get path, params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
|
['/wiki/title/ainu_title', '/tags/name/ainu_title/description'].each do |path|
|
||||||
get path, params: { locale: 'ain' }
|
get path, params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('body')).to eq('Ainu body')
|
expect(json.fetch('body')).to eq('Ainu body')
|
||||||
end
|
end
|
||||||
get '/wiki/title/ainu_title/exists', params: { locale: 'ain' }
|
get '/wiki/title/ainu_title/exists', params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:no_content)
|
expect(response).to have_http_status(:no_content)
|
||||||
get '/wiki/title/japanese_title/exists', params: { locale: 'ain' }
|
get '/wiki/title/japanese_title/exists', params: { locale: 'ain-Kana' }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -57,14 +57,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
revision = current_description_version(japanese)
|
revision = current_description_version(japanese)
|
||||||
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
||||||
"/wiki/#{ japanese.id }/diff"].each do |path|
|
"/wiki/#{ japanese.id }/diff"].each do |path|
|
||||||
get path, params: { locale: 'ain', from: revision.id }
|
get path, params: { locale: 'ain-Kana', from: revision.id }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
expect(response.body).not_to include('Japanese body')
|
expect(response.body).not_to include('Japanese body')
|
||||||
end
|
end
|
||||||
get '/wiki/changes', params: { locale: 'ain', id: japanese.id }
|
get '/wiki/changes', params: { locale: 'ain-Kana', id: japanese.id }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to eq([])
|
expect(json).to eq([])
|
||||||
get '/wiki/changes', params: { locale: 'ain' }
|
get '/wiki/changes', params: { locale: 'ain-Kana' }
|
||||||
expect(json.map { _1.fetch('revision_id') })
|
expect(json.map { _1.fetch('revision_id') })
|
||||||
.to eq([current_description_version(ainu).id])
|
.to eq([current_description_version(ainu).id])
|
||||||
end
|
end
|
||||||
@@ -73,7 +73,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
||||||
expect do
|
expect do
|
||||||
put "/wiki/#{ japanese.id }", params: {
|
put "/wiki/#{ japanese.id }", params: {
|
||||||
locale: 'ain', title: 'renamed', body: 'wrong target' }, headers: headers
|
locale: 'ain-Kana', title: 'renamed', body: 'wrong target' }, headers: headers
|
||||||
end.not_to change(TagDescriptionVersion, :count)
|
end.not_to change(TagDescriptionVersion, :count)
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
|
expect([japanese.reload.attributes, ainu.reload.attributes, ainu_name.reload.attributes])
|
||||||
@@ -89,10 +89,10 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
snapshot = original.attributes
|
snapshot = original.attributes
|
||||||
expect do
|
expect do
|
||||||
if api == :wiki
|
if api == :wiki
|
||||||
post '/wiki', params: { locale: 'ain', title: 'other_ainu', body: 'created' },
|
post '/wiki', params: { locale: 'ain-Kana', title: 'other_ainu', body: 'created' },
|
||||||
headers: headers
|
headers: headers
|
||||||
else
|
else
|
||||||
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' },
|
put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
|
||||||
headers: headers
|
headers: headers
|
||||||
end
|
end
|
||||||
end.to change(TagDescription, :count).by(1)
|
end.to change(TagDescription, :count).by(1)
|
||||||
@@ -108,20 +108,20 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
it 'shares selected-language edits and revision identity across both APIs' do
|
it 'shares selected-language edits and revision identity across both APIs' do
|
||||||
snapshot = japanese.attributes
|
snapshot = japanese.attributes
|
||||||
first = current_description_version(ainu)
|
first = current_description_version(ainu)
|
||||||
put "/tags/#{ tag.id }/description", params: { locale: 'ain', body: 'native edit' },
|
put "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana', body: 'native edit' },
|
||||||
headers: headers
|
headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
second = current_description_version(ainu)
|
second = current_description_version(ainu)
|
||||||
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
||||||
expect(second.id).not_to eq(first.id)
|
expect(second.id).not_to eq(first.id)
|
||||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain' }
|
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana' }
|
||||||
expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
|
expect(json).to include('body' => 'native edit', 'revision_id' => second.id)
|
||||||
|
|
||||||
put "/wiki/#{ ainu.id }", params: {
|
put "/wiki/#{ ainu.id }", params: {
|
||||||
locale: 'ain', title: 'ainu_title', body: 'facade edit',
|
locale: 'ain-Kana', title: 'ainu_title', body: 'facade edit',
|
||||||
base_revision_id: second.id }, headers: headers
|
base_revision_id: second.id }, headers: headers
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
get "/tags/#{ tag.id }/description", params: { locale: 'ain' }
|
get "/tags/#{ tag.id }/description", params: { locale: 'ain-Kana' }
|
||||||
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
|
expect(json).to include('body' => 'facade edit', 'language_code' => 'ain', 'version_no' => 3)
|
||||||
expect(japanese.reload.attributes).to eq(snapshot)
|
expect(japanese.reload.attributes).to eq(snapshot)
|
||||||
end
|
end
|
||||||
@@ -133,7 +133,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
|
snapshot = [ainu.reload.attributes, ainu_name.attributes, japanese.attributes]
|
||||||
expect do
|
expect do
|
||||||
put "/wiki/#{ ainu.id }", params: {
|
put "/wiki/#{ ainu.id }", params: {
|
||||||
locale: 'ain', title: 'conflicting_name', body: 'current',
|
locale: 'ain-Kana', title: 'conflicting_name', body: 'current',
|
||||||
base_revision_id: stale.id }, headers: headers
|
base_revision_id: stale.id }, headers: headers
|
||||||
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
||||||
expect(response).to have_http_status(:conflict)
|
expect(response).to have_http_status(:conflict)
|
||||||
@@ -145,14 +145,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
|||||||
first = current_description_version(ainu)
|
first = current_description_version(ainu)
|
||||||
TagDescriptionOperation.update!(
|
TagDescriptionOperation.update!(
|
||||||
ainu_locale, tag:, body: 'later', updated_by_user: user)
|
ainu_locale, tag:, body: 'later', updated_by_user: user)
|
||||||
get "/wiki/#{ ainu.id }", params: { locale: 'ain', version: first.id }
|
get "/wiki/#{ ainu.id }", params: { locale: 'ain-Kana', version: first.id }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
|
expect(json).to include('body' => 'Ainu body', 'revision_id' => first.id)
|
||||||
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain', from: first.id }
|
get "/wiki/#{ ainu.id }/diff", params: { locale: 'ain-Kana', from: first.id }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
expect(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
||||||
get "/wiki/#{ ainu.id }/diff", params: {
|
get "/wiki/#{ ainu.id }/diff", params: {
|
||||||
locale: 'ain', from: current_description_version(japanese).id }
|
locale: 'ain-Kana', from: current_description_version(japanese).id }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe PostBulkCreator do
|
RSpec.describe PostBulkCreator do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'limits workers to two and keeps failures in their request slots' do
|
it 'limits workers to two and keeps failures in their request slots' do
|
||||||
locale = Locale.nipponese
|
locale = Locale.nipponese
|
||||||
@@ -67,8 +67,7 @@ RSpec.describe PostBulkCreator do
|
|||||||
expect(results[3].dig(:post, :id)).to eq(4)
|
expect(results[3].dig(:post, :id)).to eq(4)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'passes a future English locale to preflight and creation in every worker' do
|
it 'passes a future Ainu locale to preflight and creation in every worker' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
actor = instance_double(User, id: 123)
|
actor = instance_double(User, id: 123)
|
||||||
allow(User).to receive(:find).with(123).and_return(actor)
|
allow(User).to receive(:find).with(123).and_return(actor)
|
||||||
|
|||||||
@@ -11,23 +11,22 @@ RSpec.describe PostCreatePlan do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'with an explicit locale' do
|
context 'with an explicit locale' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
it 'resolves the matching language identity and its parents' do
|
it 'resolves the matching language identity and its parents' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
english_name = create(:tag_name, name: 'shared_name',
|
ainu_name = create(:tag_name, name: 'shared_name',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
|
||||||
nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general)
|
nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general)
|
||||||
# The DB permits this identity; validation has its own contract spec.
|
# The DB permits this identity; validation has its own contract spec.
|
||||||
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
|
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
|
||||||
english_parent_name = create(:tag_name, name: 'english_parent',
|
ainu_parent_name = create(:tag_name, name: 'ainu_parent',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english_parent = create(:tag, primary_tag_name: english_parent_name,
|
ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
|
||||||
category: :material)
|
category: :material)
|
||||||
nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general)
|
nipponese_parent = create(:tag, primary_name: 'nipponese_parent', category: :general)
|
||||||
TagImplication.create!(tag: english, parent_tag: english_parent)
|
TagImplication.create!(tag: ainu, parent_tag: ainu_parent)
|
||||||
TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent)
|
TagImplication.create!(tag: nipponese, parent_tag: nipponese_parent)
|
||||||
|
|
||||||
plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale)
|
plan = described_class.new(attributes: { tags: 'shared_name' }).build!(locale)
|
||||||
@@ -35,35 +34,34 @@ RSpec.describe PostCreatePlan do
|
|||||||
expect(plan[:direct_tag_specs]).to eq(
|
expect(plan[:direct_tag_specs]).to eq(
|
||||||
[{ name: 'shared_name', category: :character }])
|
[{ name: 'shared_name', category: :character }])
|
||||||
expect(plan[:post_tag_specs]).to include(
|
expect(plan[:post_tag_specs]).to include(
|
||||||
{ name: primary_tag_name_for(english_parent, 'en').name, category: :material })
|
{ name: primary_tag_name_for(ainu_parent, 'ain').name, category: :material })
|
||||||
expect(plan[:post_tag_specs].pluck(:name))
|
expect(plan[:post_tag_specs].pluck(:name))
|
||||||
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
|
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'resolves aliases within the requested language' do
|
it 'resolves aliases within the requested language' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||||
english_name = create(:tag_name, name: 'english_canonical',
|
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
|
||||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: nipponese)
|
tag: nipponese)
|
||||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||||
tag: english,
|
tag: ainu,
|
||||||
language_code: 'en', script_code: 'Latn')
|
language_code: 'ain', script_code: 'Kana')
|
||||||
|
|
||||||
expect(nipponese_alias.reload).to have_attributes(
|
expect(nipponese_alias.reload).to have_attributes(
|
||||||
tag_id: nipponese.id,
|
tag_id: nipponese.id,
|
||||||
primary_flg: false, language_code: 'ja')
|
primary_flg: false, language_code: 'ja')
|
||||||
expect(english_alias.reload).to have_attributes(
|
expect(ainu_alias.reload).to have_attributes(
|
||||||
tag_id: english.id,
|
tag_id: ainu.id,
|
||||||
primary_flg: false, language_code: 'en')
|
primary_flg: false, language_code: 'ain')
|
||||||
|
|
||||||
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
|
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
|
||||||
|
|
||||||
expect(plan[:direct_tag_specs]).to eq(
|
expect(plan[:direct_tag_specs]).to eq(
|
||||||
[{ name: english_name.name, category: :character }])
|
[{ name: ainu_name.name, category: :character }])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,10 @@ RSpec.describe PostCreator do
|
|||||||
end
|
end
|
||||||
|
|
||||||
context 'with an explicit locale' do
|
context 'with an explicit locale' do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
[false, true].each do |planned|
|
[false, true].each do |planned|
|
||||||
it "passes locale to tag creation with planned attributes: #{ planned }" do
|
it "passes locale to tag creation with planned attributes: #{ planned }" do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
|
||||||
locale
|
locale
|
||||||
attributes = {
|
attributes = {
|
||||||
title: 'Locale propagation',
|
title: 'Locale propagation',
|
||||||
@@ -97,8 +96,8 @@ RSpec.describe PostCreator do
|
|||||||
|
|
||||||
expect(Tag).to have_received(:find_or_create_by_tag_name!)
|
expect(Tag).to have_received(:find_or_create_by_tag_name!)
|
||||||
.with(locale, 'locale_character', category: :character).at_least(:once)
|
.with(locale, 'locale_character', category: :character).at_least(:once)
|
||||||
tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character')
|
tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
|
||||||
expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true)
|
expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
|
||||||
expect(post.tags).to include(tag_name.tag)
|
expect(post.tags).to include(tag_name.tag)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TagNameCompletion do
|
||||||
|
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
|
||||||
|
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
|
||||||
|
let(:locale) do
|
||||||
|
Locale.create!(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana',
|
||||||
|
region_code: 392, variants: '', name: 'ain-Kana')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'generates a phonetic primary with the required identity and provenance' do
|
||||||
|
tag = create(:tag, primary_name: '漢字あカ')
|
||||||
|
|
||||||
|
locale
|
||||||
|
|
||||||
|
expect(tag.reload.tag_name('ain')).to have_attributes(
|
||||||
|
name: 'カンチ_ア_カ', language_code: 'ain', script_code: 'Kana',
|
||||||
|
primary_flg: true, auto_generated: true, tag_id: tag.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the exact fallback when the input cannot be converted' do
|
||||||
|
tag = create(:tag, primary_name: '😀')
|
||||||
|
|
||||||
|
locale
|
||||||
|
|
||||||
|
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the exact fallback when a generated name collides with a human name' do
|
||||||
|
other = create(:tag, primary_name: 'other')
|
||||||
|
create(:tag_name, tag: other, name: 'AbC', language_code: 'ain', script_code: 'Kana')
|
||||||
|
tag = create(:tag, primary_name: 'AbC')
|
||||||
|
|
||||||
|
locale
|
||||||
|
|
||||||
|
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
|
||||||
|
expect(other.reload.name('ain')).to eq('AbC')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'uses the exact fallback for a transcription that fails name validation' do
|
||||||
|
tag = create(:tag, primary_name: 'base')
|
||||||
|
source = TagName.new(name: 'nico:invalid', language_code: 'ja')
|
||||||
|
|
||||||
|
expect(TagName.generate_name(locale, tag, source)).to eq("Tag_##{ tag.id }")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves human confirmation and is idempotent across repeated completion' do
|
||||||
|
locale
|
||||||
|
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'あいぬ', category: :general)
|
||||||
|
primary = tag.tag_name('ain')
|
||||||
|
primary.update!(name: 'human_confirmed', auto_generated: false)
|
||||||
|
snapshot = tag.tag_names.order(:id).map(&:attributes)
|
||||||
|
|
||||||
|
2.times { described_class.for_tag!(tag) }
|
||||||
|
|
||||||
|
expect(tag.tag_names.order(:id).map(&:attributes)).to eq(snapshot)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates one primary per active language through the production creation path' do
|
||||||
|
locale
|
||||||
|
|
||||||
|
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'アイヌ', category: :general)
|
||||||
|
|
||||||
|
expect(tag.tag_names.where(primary_flg: true).pluck(:language_code, :name))
|
||||||
|
.to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ'])
|
||||||
|
expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe TagNameTranscription do
|
||||||
|
{
|
||||||
|
'AbCdXYZ' => 'AbCdXYZ',
|
||||||
|
'あいぬ' => 'アイヌ',
|
||||||
|
'アイヌ' => 'アイヌ',
|
||||||
|
'日本語' => 'ニチホンコ',
|
||||||
|
'漢字' => 'カンチ',
|
||||||
|
'漢あカ' => 'カン_ア_カ',
|
||||||
|
'漢字あいうカナ' => 'カンチ_アイウ_カナ',
|
||||||
|
'Ab漢あカZ' => 'Abカン_ア_カZ',
|
||||||
|
'りゃリュリョ' => 'リヤ_リユリヨ',
|
||||||
|
'キャット' => 'キヤット',
|
||||||
|
'コーヒー' => 'コヒ',
|
||||||
|
'コウコオケイケエカア' => 'コウコオケイケエカア',
|
||||||
|
'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト',
|
||||||
|
'バビブベボパピプペポ' => 'パピプペポパピプペポ',
|
||||||
|
'ザジズゼゾ' => 'チャチチュチェチョ',
|
||||||
|
'ジャジュジョ' => 'チヤチユチヨ',
|
||||||
|
'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚',
|
||||||
|
'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ',
|
||||||
|
'漢Aあ' => 'カンAア' }.each do |source, expected|
|
||||||
|
it "transcribes #{ source } as #{ expected }" do
|
||||||
|
expect(described_class.ainu(source)).to eq(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
['😀', 'ヴ', 'ティ', 'ㇵ', '魑', '𠮷', 'ー', '々'].each do |source|
|
||||||
|
it "rejects unsupported input #{ source }" do
|
||||||
|
expect { described_class.ainu(source) }.to raise_error(described_class::Unconvertible)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe TagVersionRecorder do
|
RSpec.describe TagVersionRecorder do
|
||||||
include_context 'English locale'
|
include_context 'Ainu locale'
|
||||||
|
|
||||||
let(:user) { create_member_user! }
|
let(:user) { create_member_user! }
|
||||||
let!(:tag) { create(:tag, primary_name: '日本語名') }
|
let!(:tag) { create(:tag, primary_name: '日本語名') }
|
||||||
@@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do
|
|||||||
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
|
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def prepare_english_names!
|
def prepare_ainu_names!
|
||||||
locale
|
locale
|
||||||
primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false)
|
primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false)
|
||||||
create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn',
|
create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana',
|
||||||
name: 'English alias')
|
name: 'Ainu alias')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
|
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
|
||||||
@@ -62,35 +62,32 @@ RSpec.describe TagVersionRecorder do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'snapshots future languages together without a request locale argument' do
|
it 'snapshots future languages together without a request locale argument' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_names!
|
||||||
prepare_english_names!
|
|
||||||
|
|
||||||
version = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
version = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||||
|
|
||||||
expect(version.names).to eq(
|
expect(version.names).to eq(
|
||||||
'ja' => nipponese_snapshot,
|
'ja' => nipponese_snapshot,
|
||||||
'en' => { 'name' => 'English name', 'script_code' => 'Latn',
|
'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana',
|
||||||
'auto_generated' => false,
|
'auto_generated' => false,
|
||||||
'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] })
|
'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] })
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'creates a version for an English-only change and preserves prior names' do
|
it 'creates a version for an Ainu-only change and preserves prior names' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_names!
|
||||||
prepare_english_names!
|
|
||||||
original = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
original = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||||
primary_tag_name_for(tag, 'en').update!(name: 'Renamed')
|
primary_tag_name_for(tag, 'ain').update!(name: 'Renamed')
|
||||||
|
|
||||||
updated = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
updated = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
||||||
|
|
||||||
expect(updated.version_no).to eq(2)
|
expect(updated.version_no).to eq(2)
|
||||||
expect(original.reload.names.fetch('en')).to include('name' => 'English name')
|
expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
|
||||||
expect(updated.names.fetch('en')).to include('name' => 'Renamed')
|
expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
|
||||||
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
|
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does not create a version for an unchanged multilingual snapshot' do
|
it 'does not create a version for an unchanged multilingual snapshot' do
|
||||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
prepare_ainu_names!
|
||||||
prepare_english_names!
|
|
||||||
described_class.record!(tag:, event_type: :create, created_by_user: user)
|
described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||||
|
|
||||||
unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
RSpec.shared_context 'Ainu locale contract' do
|
RSpec.shared_context 'Ainu locale contract' do
|
||||||
let(:ainu_locale) { instance_double(Locale, language_code: 'ain', script_code: 'Jpan') }
|
let(:ainu_locale) do
|
||||||
|
instance_double(Locale, language_code: 'ain', script_code: 'Kana', deprecated_at: nil)
|
||||||
|
end
|
||||||
|
|
||||||
before do
|
before do
|
||||||
# Only the foreign-key reference is seeded; no Locale or enum is enabled.
|
# Keep description contracts independent of locale activation and backfill.
|
||||||
Language.connection.execute(<<~SQL)
|
Language.connection.execute(<<~SQL)
|
||||||
INSERT IGNORE INTO languages (code, name, created_at)
|
INSERT IGNORE INTO languages (code, name, created_at)
|
||||||
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
|
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
|
||||||
SQL
|
SQL
|
||||||
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||||
allow(Locale).to receive(:find_by).and_call_original
|
allow(Locale).to receive(:find_by).and_call_original
|
||||||
allow(Locale).to receive(:find_by).with(code: 'ain').and_return(ainu_locale)
|
allow(Locale).to receive(:find_by).with(code: 'ain-Kana').and_return(ainu_locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
def add_ainu_name tag, name
|
def add_ainu_name tag, name
|
||||||
TagName.insert_all!([{
|
TagName.insert_all!([{
|
||||||
tag_id: tag.id, language_code: 'ain', script_code: 'Jpan', name:,
|
tag_id: tag.id, language_code: 'ain', script_code: 'Kana', name:,
|
||||||
primary_flg: true, auto_generated: false,
|
primary_flg: true, auto_generated: false,
|
||||||
created_at: Time.current, updated_at: Time.current }])
|
created_at: Time.current, updated_at: Time.current }])
|
||||||
tag.tag_names.reset
|
tag.tag_names.reset
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
RSpec.shared_context 'English locale' do
|
RSpec.shared_context 'Ainu locale' do
|
||||||
def prepare_english_reference!
|
def prepare_ainu_reference!
|
||||||
Language.find_or_create_by!(code: 'en') { _1.name = 'English' }
|
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||||
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||||
region_class = Class.new(ApplicationRecord) { self.table_name = 'regions' }
|
|
||||||
region_class.find_or_create_by!(code: 826) do |record|
|
|
||||||
record.alpha_code = 'GB'
|
|
||||||
record.name = 'United Kingdom'
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:locale) do
|
let(:locale) do
|
||||||
prepare_english_reference!
|
prepare_ainu_reference!
|
||||||
Locale.find_or_create_by!(code: 'en') do |record|
|
Locale.find_or_create_by!(code: 'ain-Kana') do |record|
|
||||||
record.language_code = 'en'
|
record.language_code = 'ain'
|
||||||
record.script_code = 'Latn'
|
record.script_code = 'Kana'
|
||||||
record.region_code = 826
|
record.region_code = 392
|
||||||
record.variants = ''
|
record.variants = ''
|
||||||
record.name = 'English'
|
record.name = 'Ainu'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" />
|
<meta name="description" lang="ja" data-rh="true" content="ぼざクリ ぼざろクリーチャーシリーズ 伊地知ニジカ リンク集 Wiki ニコニコ" />
|
||||||
<title>ぼざクリ タグ広場 〜 ぼざろクリーチャーシリーズ綜合リンク集</title>
|
<title>ぼざクリ タグ広場 〜 ぼざろクリーチャーシリーズ綜合リンク集</title>
|
||||||
<!-- helmet -->
|
<!-- helmet -->
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
生成ファイル
+78
-3
@@ -25,12 +25,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"humps": "^2.0.1",
|
"humps": "^2.0.1",
|
||||||
|
"i18next": "^26.4.7",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"path-to-regexp": "^8.3.0",
|
"path-to-regexp": "^8.3.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
|
"react-i18next": "^17.0.20",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-markdown-editor-lite": "^1.3.4",
|
"react-markdown-editor-lite": "^1.3.4",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
@@ -350,9 +352,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.6",
|
"version": "7.29.10",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -4872,6 +4874,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -4916,6 +4927,34 @@
|
|||||||
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
|
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.7.tgz",
|
||||||
|
"integrity": "sha512-p/3hqy6EcuqRs6wNwBYwGGC1fzBJSMxV9zMuN814ab/7QjdZbXfjXwuPZdQbngX6KDOTxM01uUiZyqmMB8ghZg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
@@ -7258,6 +7297,33 @@
|
|||||||
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
"react": "^16.6.0 || ^17.0.0 || ^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.20.tgz",
|
||||||
|
"integrity": "sha512-rzSSirf0NP9vfcUXHZAksIuyMWl7L/T/7nm8S+sSIFuIzku54qcyyVAOZXbawPNpUxGkDx/eosSv4DrpzcxuOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"html-parse-stringify": "^4.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "16.13.1",
|
"version": "16.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
@@ -8595,6 +8661,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@@ -29,12 +29,14 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"humps": "^2.0.1",
|
"humps": "^2.0.1",
|
||||||
|
"i18next": "^26.4.7",
|
||||||
"lucide-react": "^0.511.0",
|
"lucide-react": "^0.511.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"path-to-regexp": "^8.3.0",
|
"path-to-regexp": "^8.3.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-helmet-async": "^2.0.5",
|
"react-helmet-async": "^2.0.5",
|
||||||
|
"react-i18next": "^17.0.20",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-markdown-editor-lite": "^1.3.4",
|
"react-markdown-editor-lite": "^1.3.4",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-router-dom": "^6.30.1",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ const formatDate = (date = new Date) => {
|
|||||||
return `${ year }-${ month }-${ day }`
|
return `${ year }-${ month }-${ day }`
|
||||||
}
|
}
|
||||||
|
|
||||||
const SITE_TITLE = 'ぼざクリ タグ広場'
|
const SITE_TITLE = JSON.parse (fs.readFileSync (
|
||||||
|
new URL ('../src/locales/ja/shared.json', import.meta.url), 'utf8')).labels.brand
|
||||||
const DOMAIN = 'https://hub.nizika.monster'
|
const DOMAIN = 'https://hub.nizika.monster'
|
||||||
const API_BASE_URL = 'https://hub.nizika.monster/api'
|
const API_BASE_URL = 'https://hub.nizika.monster/api'
|
||||||
|
|
||||||
|
|||||||
+118
-38
@@ -1,18 +1,26 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
import { AnimatePresence, LayoutGroup, MotionConfig, motion } from 'framer-motion'
|
||||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
import { Fragment, Suspense, lazy, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { createBrowserRouter,
|
import { createBrowserRouter,
|
||||||
Navigate,
|
Navigate,
|
||||||
Route,
|
Route,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
Routes,
|
Routes,
|
||||||
useLocation } from 'react-router-dom'
|
useLocation,
|
||||||
|
useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||||
import TopNav from '@/components/TopNav'
|
import TopNav from '@/components/TopNav'
|
||||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||||
|
import PageHead from '@/components/common/PageHead'
|
||||||
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 { fetchLocales, isLocaleCode, legacyLocalePath, switchLocalePath } from '@/lib/locales'
|
||||||
|
import { localesKeys } from '@/lib/queryKeys'
|
||||||
|
import { requiredPageLocale } from '@/lib/routeLocales'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
fetchUserThemeSlots,
|
fetchUserThemeSlots,
|
||||||
@@ -33,10 +41,10 @@ import MaterialListPage from '@/pages/materials/MaterialListPage'
|
|||||||
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
import MaterialNewPage from '@/pages/materials/MaterialNewPage'
|
||||||
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
import MaterialSyncSuppressionsPage from '@/pages/materials/MaterialSyncSuppressionsPage'
|
||||||
import MorePage from '@/pages/MorePage'
|
import MorePage from '@/pages/MorePage'
|
||||||
import GekanatorPage from '@/pages/GekanatorPage'
|
import GekanatorUnavailablePage from '@/pages/GekanatorUnavailablePage'
|
||||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||||
import NotFound from '@/pages/NotFound'
|
import NotFound from '@/pages/NotFound'
|
||||||
import TOSPage from '@/pages/TOSPage.mdx'
|
import TOSPage from '@/pages/TOSPage'
|
||||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||||
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
||||||
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
import HelpDiffPage from '@/pages/help/HelpDiffPage'
|
||||||
@@ -62,8 +70,26 @@ import WikiSearchPage from '@/pages/wiki/WikiSearchPage'
|
|||||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { ClientAnimationMode } from '@/lib/settings'
|
import type { ClientAnimationMode } from '@/lib/settings'
|
||||||
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
|
const GekanatorPage = lazy (() => import ('@/pages/GekanatorPage'))
|
||||||
|
|
||||||
|
|
||||||
|
const GekanatorRoute = ({ user }: { user: User | null }) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const location = useLocation ()
|
||||||
|
const pageLocale = requiredPageLocale (location.pathname)
|
||||||
|
|
||||||
|
if (pageLocale != null && pageLocale !== locale)
|
||||||
|
return <GekanatorUnavailablePage/>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<GekanatorPage user={user}/>
|
||||||
|
</Suspense>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||||
animationMode: ClientAnimationMode
|
animationMode: ClientAnimationMode
|
||||||
@@ -73,41 +99,41 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
|||||||
|
|
||||||
const routes = (
|
const routes = (
|
||||||
<Routes location={location}>
|
<Routes location={location}>
|
||||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
<Route path="" element={<LocaleRedirect to="/posts"/>}/>
|
||||||
<Route path="/posts" element={<PostListPage/>}/>
|
<Route path="posts" element={<PostListPage/>}/>
|
||||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
<Route path="posts/new" element={<PostNewPage user={user}/>}/>
|
||||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
<Route path="posts/search" element={<PostSearchPage/>}/>
|
||||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
<Route path="posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
<Route path="posts/changes" element={<PostHistoryPage/>}/>
|
||||||
<Route path="/tags" element={<TagListPage/>}/>
|
<Route path="tags" element={<TagListPage/>}/>
|
||||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
<Route path="tags/:id" element={<TagDetailPage/>}/>
|
||||||
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
<Route path="tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
||||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
<Route path="tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
<Route path="nico/tags" element={<NicoTagListPage user={user}/>}/>
|
||||||
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
<Route path="tags/changes" element={<TagHistoryPage/>}/>
|
||||||
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
<Route path="theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
<Route path="materials" element={<MaterialBasePage/>}>
|
||||||
<Route index element={<MaterialListPage/>}/>
|
<Route index element={<MaterialListPage/>}/>
|
||||||
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
||||||
<Route path="new" element={<MaterialNewPage/>}/>
|
<Route path="new" element={<MaterialNewPage/>}/>
|
||||||
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
||||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
<Route path="wiki" element={<WikiSearchPage/>}/>
|
||||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
<Route path="wiki/:title" element={<WikiDetailPage/>}/>
|
||||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
<Route path="wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
<Route path="wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
<Route path="wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
<Route path="wiki/changes" element={<WikiHistoryPage/>}/>
|
||||||
<Route path="/help" element={<HelpListPage/>}/>
|
<Route path="help" element={<HelpListPage/>}/>
|
||||||
<Route path="/help/changes" element={<HelpChangesPage/>}/>
|
<Route path="help/changes" element={<HelpChangesPage/>}/>
|
||||||
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
<Route path="help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
<Route path="help/:identifier" element={<HelpDetailPage/>}/>
|
||||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
<Route path="users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
<Route path="settings" element={<LocaleRedirect to="/users/settings"/>}/>
|
||||||
<Route path="/tos" element={<TOSPage/>}/>
|
<Route path="tos" element={<TOSPage/>}/>
|
||||||
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
<Route path="gekanator" element={<GekanatorRoute user={user}/>}/>
|
||||||
<Route path="/more" element={<MorePage/>}/>
|
<Route path="more" element={<MorePage/>}/>
|
||||||
<Route path="*" element={<NotFound/>}/>
|
<Route path="*" element={<NotFound/>}/>
|
||||||
</Routes>)
|
</Routes>)
|
||||||
|
|
||||||
@@ -128,7 +154,7 @@ const PostDetailRoute = ({ user }: { user: User | null }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const RoutedApp: FC = () => {
|
const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
||||||
const [user, setUser] = useState<User | null> (null)
|
const [user, setUser] = useState<User | null> (null)
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
@@ -228,6 +254,7 @@ const RoutedApp: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogueProvider>
|
<DialogueProvider>
|
||||||
|
<PageHead/>
|
||||||
<UnsavedChangesGuardProvider>
|
<UnsavedChangesGuardProvider>
|
||||||
<KeyboardShortcutsProvider>
|
<KeyboardShortcutsProvider>
|
||||||
<MotionConfig
|
<MotionConfig
|
||||||
@@ -243,7 +270,7 @@ const RoutedApp: FC = () => {
|
|||||||
layout={animationMode === 'off' ? false : 'position'}
|
layout={animationMode === 'off' ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
<TopNav user={user}/>
|
<TopNav user={user} locales={locales}/>
|
||||||
<RouteTransitionWrapper
|
<RouteTransitionWrapper
|
||||||
animationMode={animationMode}
|
animationMode={animationMode}
|
||||||
user={user}
|
user={user}
|
||||||
@@ -259,9 +286,62 @@ const RoutedApp: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const router = createBrowserRouter ([{
|
const LocaleRoute: FC = () => {
|
||||||
path: '*',
|
const { locale } = useParams ()
|
||||||
element: <RoutedApp/> }])
|
const location = useLocation ()
|
||||||
|
const { i18n } = useTranslation ()
|
||||||
|
const { data, isPending, isError, refetch } = useQuery ({
|
||||||
|
queryKey: localesKeys.index,
|
||||||
|
queryFn: fetchLocales,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: 'always',
|
||||||
|
refetchOnWindowFocus: 'always',
|
||||||
|
refetchOnReconnect: 'always' })
|
||||||
|
const activeLocale =
|
||||||
|
(isLocaleCode (locale)
|
||||||
|
&& data?.locales.some (entry => entry.code === locale))
|
||||||
|
? locale
|
||||||
|
: null
|
||||||
|
|
||||||
|
useLayoutEffect (() => {
|
||||||
|
if (activeLocale != null)
|
||||||
|
document.documentElement.lang = activeLocale
|
||||||
|
if (activeLocale != null && i18n.language !== activeLocale)
|
||||||
|
void i18n.changeLanguage (activeLocale)
|
||||||
|
}, [activeLocale, i18n])
|
||||||
|
|
||||||
|
useEffect (() => {
|
||||||
|
void refetch ()
|
||||||
|
}, [location.key, refetch])
|
||||||
|
|
||||||
|
const redirect = legacyLocalePath (location)
|
||||||
|
if (!(isLocaleCode (locale)) && redirect == null)
|
||||||
|
return <NotFound/>
|
||||||
|
if (isError)
|
||||||
|
return <ServiceUnavailable/>
|
||||||
|
if (isPending)
|
||||||
|
return null
|
||||||
|
if (isLocaleCode (locale))
|
||||||
|
{
|
||||||
|
return data.locales.some (entry => entry.code === locale)
|
||||||
|
? <RoutedApp locales={data.locales}/>
|
||||||
|
: <NotFound/>
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect == null ? <NotFound/> : <Navigate to={redirect} replace/>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const LocaleRedirect = ({ to }: { to: string }) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const location = useLocation ()
|
||||||
|
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const router = createBrowserRouter ([
|
||||||
|
{ path: '/:locale/*', element: <LocaleRoute/> },
|
||||||
|
{ path: '*', element: <LocaleRoute/> }])
|
||||||
|
|
||||||
|
|
||||||
const App: FC = () => (
|
const App: FC = () => (
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -8,6 +11,8 @@ const COLUMN_COUNT = 12
|
|||||||
|
|
||||||
|
|
||||||
const DevModeWatermark: FC = () => {
|
const DevModeWatermark: FC = () => {
|
||||||
|
const { i18n } = useTranslation ('shared')
|
||||||
|
const text = translateUIMessage ('labels.development', 'shared', i18n.language)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -29,13 +34,14 @@ const DevModeWatermark: FC = () => {
|
|||||||
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||||
<span
|
<span
|
||||||
key={columnIndex}
|
key={columnIndex}
|
||||||
|
lang={text.language}
|
||||||
className={
|
className={
|
||||||
'whitespace-nowrap text-3xl font-bold '
|
'whitespace-nowrap text-3xl font-bold '
|
||||||
+ 'tracking-[0.3em] text-neutral-950/5 '
|
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||||
+ 'dark:text-white/10'
|
+ 'dark:text-white/10'
|
||||||
}
|
}
|
||||||
style={{ fontFamily: 'Nikumaru' }}>
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
開発モード
|
{text.text}
|
||||||
</span>))}
|
</span>))}
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
|
|
||||||
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||||
import errorImg from '@/assets/images/not-found.gif'
|
import errorImg from '@/assets/images/not-found.gif'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import { SITE_TITLE } from '@/config'
|
import PageHead from '@/components/common/PageHead'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -11,17 +10,16 @@ type Props = { status: number }
|
|||||||
|
|
||||||
|
|
||||||
const ErrorScreen: FC<Props> = ({ status }) => {
|
const ErrorScreen: FC<Props> = ({ status }) => {
|
||||||
const [message, rightMsg, leftMsg]: [string, string, string] = (() => {
|
const { message: translate, label } = useUITranslation ('errors')
|
||||||
|
const [message]: [string, string, string] = (() => {
|
||||||
switch (status)
|
switch (status)
|
||||||
{
|
{
|
||||||
case 403:
|
case 403:
|
||||||
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
|
|
||||||
case 404:
|
case 404:
|
||||||
return ['ページないよ(笑)', '帰れ!', '帰れ!']
|
|
||||||
case 500:
|
case 500:
|
||||||
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
|
|
||||||
case 503:
|
case 503:
|
||||||
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!']
|
return [translate (`${ status }.message`).text,
|
||||||
|
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
|
||||||
default:
|
default:
|
||||||
throw new Error
|
throw new Error
|
||||||
}
|
}
|
||||||
@@ -40,10 +38,10 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea>
|
<MainArea>
|
||||||
<Helmet>
|
<PageHead title={{ text: title,
|
||||||
|
language: translate (`${status}.message`).language }}>
|
||||||
<meta name="robots" content="noindex"/>
|
<meta name="robots" content="noindex"/>
|
||||||
<title>{title} | {SITE_TITLE}</title>
|
</PageHead>
|
||||||
</Helmet>
|
|
||||||
<div className="text-6xl font-bold text-transparent
|
<div className="text-6xl font-bold text-transparent
|
||||||
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
|
bg-[linear-gradient(90deg,#ff0000,#ff8800,#ffff00,#00ff00,#00ffff,#0000ff,#ff00ff,#ff0000)]
|
||||||
bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
|
bg-clip-text bg-[length:200%_100%] animate-rainbow-scroll drop-shadow-[0_0_6px_black]
|
||||||
@@ -51,13 +49,13 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
|||||||
style={{ fontFamily: 'Nikumaru' }}>
|
style={{ fontFamily: 'Nikumaru' }}>
|
||||||
<p>{status}</p>
|
<p>{status}</p>
|
||||||
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
||||||
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
|
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p>
|
||||||
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
|
<img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/>
|
||||||
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
|
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="mr-[-.5em]">{message}</p>
|
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
|
||||||
</div>
|
</div>
|
||||||
</MainArea>)
|
</MainArea>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ErrorScreen
|
export default ErrorScreen
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Children, cloneElement, Fragment, isValidElement } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
export const formatUIString = (text: string, language: string): string =>
|
||||||
|
(language === 'ain' || language === 'ain-Kana')
|
||||||
|
? text.replace (/[ \t\r\n]+/g, '\u3000')
|
||||||
|
: text
|
||||||
|
|
||||||
|
|
||||||
|
const splitAinuText = (text: string): string[] => {
|
||||||
|
const parts: string[] = []
|
||||||
|
let part = ''
|
||||||
|
let boundary = false
|
||||||
|
for (const character of text)
|
||||||
|
{
|
||||||
|
// Keep punctuation runs and closing brackets/quotes with the preceding word.
|
||||||
|
if (boundary && !/[\u3000、,,。.\p{Pe}\p{Pf}]/u.test (character))
|
||||||
|
{
|
||||||
|
parts.push (part)
|
||||||
|
part = ''
|
||||||
|
boundary = false
|
||||||
|
}
|
||||||
|
part += character
|
||||||
|
if (/[\u3000、,,。.]/u.test (character))
|
||||||
|
boundary = true
|
||||||
|
}
|
||||||
|
if (part !== '')
|
||||||
|
parts.push (part)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const localiseUIChildren = (children: ReactNode, language: string): ReactNode => {
|
||||||
|
if (language !== 'ain' && language !== 'ain-Kana')
|
||||||
|
return children
|
||||||
|
|
||||||
|
return Children.map (children, child => {
|
||||||
|
if (typeof child === 'string')
|
||||||
|
{
|
||||||
|
const parts = splitAinuText (formatUIString (child, language))
|
||||||
|
return parts.map ((part, index) => {
|
||||||
|
const spaces = part.match (/\u3000+$/u)?.[0] ?? ''
|
||||||
|
const word = part.slice (0, part.length - spaces.length)
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{word !== '' && <span className="ainu-ui-word">{word}</span>}
|
||||||
|
{spaces}
|
||||||
|
{index < parts.length - 1 && <wbr/>}
|
||||||
|
</Fragment>)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValidElement<{ children?: ReactNode; lang?: string }> (child))
|
||||||
|
{
|
||||||
|
// Only traverse inline formatting owned by the renderer. Components,
|
||||||
|
// links and code retain their own children and behaviour.
|
||||||
|
const inline = child.type === Fragment
|
||||||
|
|| (typeof child.type === 'string'
|
||||||
|
&& ['span', 'em', 'strong', 'del', 's', 'b', 'i', 'u',
|
||||||
|
'small', 'mark', 'sup', 'sub'].includes (child.type))
|
||||||
|
if (!inline
|
||||||
|
|| (child.props.lang != null && !(child.props.lang.startsWith ('ain'))))
|
||||||
|
return child
|
||||||
|
return cloneElement (child, { }, localiseUIChildren (child.props.children, language))
|
||||||
|
}
|
||||||
|
|
||||||
|
return child
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const LocalisedText = (
|
||||||
|
{ children, language, className }: {
|
||||||
|
children: ReactNode
|
||||||
|
language: string
|
||||||
|
className?: string },
|
||||||
|
) => (
|
||||||
|
<span lang={language === 'ain' ? 'ain-Kana' : language}
|
||||||
|
className={cn ((language === 'ain' || language === 'ain-Kana') && 'ainu-ui-text',
|
||||||
|
className)}>
|
||||||
|
{localiseUIChildren (children, language)}
|
||||||
|
</span>)
|
||||||
|
|
||||||
|
|
||||||
|
export default LocalisedText
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||||||
import { materialsKeys } from '@/lib/queryKeys'
|
import { materialsKeys } from '@/lib/queryKeys'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
import type { CSSProperties, Dispatch, FC, ReactNode, SetStateAction } from 'react'
|
||||||
|
|
||||||
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
import type { MaterialFilter, MaterialSidebarTag, Tag } from '@/types'
|
||||||
|
|
||||||
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
|
const FILTERS: MaterialFilter[] = ['missing', 'present', 'any']
|
||||||
const FILTER_LABELS: Record<MaterialFilter, string> = { present: '有', missing: '無', any: '全' }
|
|
||||||
|
|
||||||
|
|
||||||
const px = (value: string): number => {
|
const px = (value: string): number => {
|
||||||
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
|
|||||||
const updateMaterialFilterQuery = (
|
const updateMaterialFilterQuery = (
|
||||||
pathname: string,
|
pathname: string,
|
||||||
locationSearch: string,
|
locationSearch: string,
|
||||||
navigate: ReturnType<typeof useNavigate>,
|
navigate: ReturnType<typeof useLocaleNavigate>,
|
||||||
materialFilter: MaterialFilter,
|
materialFilter: MaterialFilter,
|
||||||
) => {
|
) => {
|
||||||
const qs = new URLSearchParams (locationSearch)
|
const qs = new URLSearchParams (locationSearch)
|
||||||
@@ -95,24 +96,27 @@ const updateMaterialFilterQuery = (
|
|||||||
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
const MaterialFilterButtons: FC<{ materialFilter: MaterialFilter
|
||||||
onChange: (materialFilter: MaterialFilter) => void }> = (
|
onChange: (materialFilter: MaterialFilter) => void }> = (
|
||||||
{ materialFilter, onChange },
|
{ materialFilter, onChange },
|
||||||
) => (
|
) => {
|
||||||
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
|
const { label } = useUITranslation ('materials')
|
||||||
<label className="my-auto text-sm font-bold">素材:</label>
|
return (
|
||||||
{FILTERS.map (value => (
|
<div className="flex flex-wrap gap-2 justify-end md:justify-start flex-center">
|
||||||
<button
|
<label className="my-auto text-sm font-bold">{label ('sidebar.label')}</label>
|
||||||
key={value}
|
{FILTERS.map (value => (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onChange (value)}
|
key={value}
|
||||||
className={cn (
|
type="button"
|
||||||
'rounded-full border px-3 py-1 text-sm',
|
onClick={() => onChange (value)}
|
||||||
(materialFilter === value
|
className={cn (
|
||||||
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
'rounded-full border px-3 py-1 text-sm',
|
||||||
'dark:bg-sky-950 dark:text-sky-100']
|
(materialFilter === value
|
||||||
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
|
? ['border-sky-500 bg-sky-50 text-sky-700 dark:border-sky-400',
|
||||||
'dark:bg-stone-900 dark:text-stone-200']))}>
|
'dark:bg-sky-950 dark:text-sky-100']
|
||||||
{FILTER_LABELS[value]}
|
: ['border-neutral-300 bg-white text-neutral-700 dark:border-stone-700',
|
||||||
</button>))}
|
'dark:bg-stone-900 dark:text-stone-200']))}>
|
||||||
</div>)
|
{label (`sidebar.${value}`)}
|
||||||
|
</button>))}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const MaterialTreeNode: FC<{
|
const MaterialTreeNode: FC<{
|
||||||
@@ -125,10 +129,11 @@ const MaterialTreeNode: FC<{
|
|||||||
tag: MaterialSidebarTag
|
tag: MaterialSidebarTag
|
||||||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
||||||
setOpenTags, tag }) => {
|
setOpenTags, tag }) => {
|
||||||
|
const locale = useLocale ()
|
||||||
const open = Boolean (openTags[tag.id])
|
const open = Boolean (openTags[tag.id])
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -200,6 +205,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
|||||||
tag,
|
tag,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
const locale = useLocale ()
|
||||||
const open = Boolean (openTags[tag.id])
|
const open = Boolean (openTags[tag.id])
|
||||||
const tagColumnRef = useRef<HTMLDivElement | null> (null)
|
const tagColumnRef = useRef<HTMLDivElement | null> (null)
|
||||||
const chipRef = useRef<HTMLDivElement | null> (null)
|
const chipRef = useRef<HTMLDivElement | null> (null)
|
||||||
@@ -210,8 +216,8 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
|||||||
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
|
const [tagLinkInlineSizePx, setTagLinkInlineSizePx] = useState<number | null> (null)
|
||||||
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
const [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -382,8 +388,10 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
|||||||
|
|
||||||
|
|
||||||
const MaterialSidebar: FC = () => {
|
const MaterialSidebar: FC = () => {
|
||||||
|
const { label } = useUITranslation ('materials')
|
||||||
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const qs = new URLSearchParams (location.search)
|
const qs = new URLSearchParams (location.search)
|
||||||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
||||||
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
||||||
@@ -395,8 +403,8 @@ const MaterialSidebar: FC = () => {
|
|||||||
useState<number | null> (null)
|
useState<number | null> (null)
|
||||||
|
|
||||||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
|
||||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setDesktopTags (rootTags)
|
setDesktopTags (rootTags)
|
||||||
@@ -494,10 +502,12 @@ const MaterialSidebar: FC = () => {
|
|||||||
materialFilter={materialFilter}
|
materialFilter={materialFilter}
|
||||||
onChange={handleFilterChange}/>
|
onChange={handleFilterChange}/>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<p className="text-sm text-neutral-500 dark:text-stone-400">読込中……</p>)}
|
<p className="text-sm text-neutral-500 dark:text-stone-400">
|
||||||
|
{label ('shared:messages.loadingEllipsis')}
|
||||||
|
</p>)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<p className="text-sm text-red-600 dark:text-red-300">
|
<p className="text-sm text-red-600 dark:text-red-300">
|
||||||
タグ一覧の取得に失敗しました.
|
{label ('sidebar.error')}
|
||||||
</p>)}
|
</p>)}
|
||||||
{(!isLoading && !isError) && (
|
{(!isLoading && !isError) && (
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { FC } from 'react'
|
|||||||
|
|
||||||
const MenuSeparator: FC = () => (
|
const MenuSeparator: FC = () => (
|
||||||
<>
|
<>
|
||||||
<span className="hidden md:inline flex items-center px-2">|</span>
|
<span className="top-nav-separator-vertical items-center px-2">|</span>
|
||||||
<hr className="block md:hidden w-full opacity-25
|
<hr className="top-nav-separator-horizontal w-full opacity-25
|
||||||
border-t border-black dark:border-white"/>
|
border-t border-black dark:border-white"/>
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
export default MenuSeparator
|
export default MenuSeparator
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import FieldError from '@/components/common/FieldError'
|
import FieldError from '@/components/common/FieldError'
|
||||||
import PostDurationField from '@/components/posts/PostDurationField'
|
import PostDurationField from '@/components/posts/PostDurationField'
|
||||||
@@ -12,6 +13,7 @@ import useDialogue from '@/lib/dialogues/useDialogue'
|
|||||||
import { updatePost } from '@/lib/posts'
|
import { updatePost } from '@/lib/posts'
|
||||||
import { msToTime } from '@/lib/utils'
|
import { msToTime } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC, FormEvent } from 'react'
|
import type { FC, FormEvent } from 'react'
|
||||||
|
|
||||||
@@ -51,6 +53,8 @@ type Props = { post: Post
|
|||||||
|
|
||||||
|
|
||||||
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { label } = useUITranslation ('posts')
|
||||||
const [disabled, setDisabled] = useState (false)
|
const [disabled, setDisabled] = useState (false)
|
||||||
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
||||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
@@ -75,7 +79,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await updatePost (...args)
|
const data = await updatePost (...args, undefined, locale)
|
||||||
onSave ({ ...post,
|
onSave ({ ...post,
|
||||||
versionNo: data.versionNo,
|
versionNo: data.versionNo,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
@@ -86,7 +90,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
siblingPosts: data.siblingPosts,
|
siblingPosts: data.siblingPosts,
|
||||||
originalCreatedFrom: data.originalCreatedFrom,
|
originalCreatedFrom: data.originalCreatedFrom,
|
||||||
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
originalCreatedBefore: data.originalCreatedBefore } as Post)
|
||||||
toast ({ description: '更新しました.' })
|
toast ({ description: label ('shared:messages.updated') })
|
||||||
}
|
}
|
||||||
catch (e)
|
catch (e)
|
||||||
{
|
{
|
||||||
@@ -96,24 +100,28 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
{
|
{
|
||||||
if (applyValidationError (e))
|
if (applyValidationError (e))
|
||||||
{
|
{
|
||||||
toast ({ description: '更新はできなかったよ……' })
|
toast ({ description: label ('edit.validationFailed') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast ({ title: '失敗……', description: '入力を確認してください.' })
|
toast ({ title: label ('shared:messages.failed'),
|
||||||
|
description: label ('shared:messages.checkInput') })
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = await dialogue.choice ({
|
const action = await dialogue.choice ({
|
||||||
title: '競合が発生しました.',
|
title: label ('edit.conflictTitle'),
|
||||||
description: (
|
description: (
|
||||||
<div>
|
<div>
|
||||||
<p>ほかの耕作員が先に更新してゐます.</p>
|
<p>{label ('edit.conflictDescription')}</p>
|
||||||
<p>現在の変更をどう扱ひますか?</p>
|
<p>{label ('edit.conflictQuestion')}</p>
|
||||||
</div>),
|
</div>),
|
||||||
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
|
cancelText: label ('shared:actions.cancel'),
|
||||||
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })
|
closeLabel: label ('settings:actions.close'),
|
||||||
|
choices: [...(response?.data?.mergeable
|
||||||
|
? [{ value: 'merge', label: label ('edit.merge') }] : []),
|
||||||
|
{ value: 'overwrite', label: label ('edit.overwrite'), variant: 'danger' }] })
|
||||||
|
|
||||||
if (action === 'merge')
|
if (action === 'merge')
|
||||||
{
|
{
|
||||||
@@ -163,13 +171,13 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
<FieldError messages={baseErrors}/>
|
<FieldError messages={baseErrors}/>
|
||||||
|
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="タイトル"
|
label={label ('shared:labels.title')}
|
||||||
value={title ?? ''}
|
value={title ?? ''}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={setTitle}/>
|
onChange={setTitle}/>
|
||||||
|
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="親投稿"
|
label={label ('shared:labels.parentPosts')}
|
||||||
value={parentPostIds}
|
value={parentPostIds}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errors={fieldErrors.parentPostIds}
|
errors={fieldErrors.parentPostIds}
|
||||||
@@ -202,7 +210,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
|||||||
|
|
||||||
{/* 送信 */}
|
{/* 送信 */}
|
||||||
<Button type="submit" disabled={disabled}>
|
<Button type="submit" disabled={disabled}>
|
||||||
更新
|
{label ('shared:actions.update')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>)
|
</form>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ 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 useDialogue from '@/lib/dialogues/useDialogue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC, RefObject } from 'react'
|
import type { FC, RefObject } from 'react'
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ const PostEmbed: FC<Props> = (
|
|||||||
onError },
|
onError },
|
||||||
) => {
|
) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const { label } = useUITranslation ('posts')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
|
const embedAutoLoadMode = behaviourSettings.embedAutoLoad ?? 'auto'
|
||||||
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
const [manualLoadRequested, setManualLoadRequested] = useState (false)
|
||||||
@@ -135,7 +137,7 @@ const PostEmbed: FC<Props> = (
|
|||||||
|
|
||||||
const externalLink = (
|
const externalLink = (
|
||||||
<a href={post.url} target="_blank" rel="noreferrer">
|
<a href={post.url} target="_blank" rel="noreferrer">
|
||||||
外部ページを開く
|
{label ('embed.openExternal')}
|
||||||
</a>)
|
</a>)
|
||||||
|
|
||||||
const manualLoadControl = (
|
const manualLoadControl = (
|
||||||
@@ -144,7 +146,7 @@ const PostEmbed: FC<Props> = (
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setManualLoadRequested (true)}>
|
onClick={() => setManualLoadRequested (true)}>
|
||||||
埋め込みを読込
|
{label ('embed.load')}
|
||||||
</Button>
|
</Button>
|
||||||
{externalLink}
|
{externalLink}
|
||||||
</div>)
|
</div>)
|
||||||
@@ -239,15 +241,15 @@ const PostEmbed: FC<Props> = (
|
|||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
|
||||||
setFramed (await dialogue.confirm ({
|
setFramed (await dialogue.confirm ({
|
||||||
title: '未確認の外部ページを表示します。',
|
title: label ('embed.confirmTitle'),
|
||||||
description: (
|
description: (
|
||||||
<div>
|
<div>
|
||||||
<p>悪意のあるスクリプトが実行される可能性があります。</p>
|
<p>{label ('embed.risk')}</p>
|
||||||
<p>表示しますか?</p>
|
<p>{label ('embed.question')}</p>
|
||||||
</div>),
|
</div>),
|
||||||
confirmText: '表示' }))
|
confirmText: label ('shared:actions.show') }))
|
||||||
}}>
|
}}>
|
||||||
外部ページを表示
|
{label ('embed.showExternal')}
|
||||||
</a>)}
|
</a>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import TextArea from '@/components/common/TextArea'
|
import TextArea from '@/components/common/TextArea'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'
|
import type { ComponentPropsWithoutRef, FC, ReactNode, SyntheticEvent } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
@@ -34,10 +36,12 @@ const replaceToken = (value: string, start: number, end: number, text: string) =
|
|||||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
|
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & {
|
||||||
tags: string
|
tags: string
|
||||||
setTags: (tags: string) => void
|
setTags: (tags: string) => void
|
||||||
errors?: string[] }
|
errors?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||||
|
const { label } = useUITranslation ('navigation')
|
||||||
|
const locale = useLocale ()
|
||||||
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 })
|
||||||
@@ -68,7 +72,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
|
|
||||||
setBounds ({ start, end })
|
setBounds ({ start, end })
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
||||||
q: token,
|
q: token,
|
||||||
nico: '0' } })
|
nico: '0' } })
|
||||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||||
@@ -77,7 +81,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField className="relative w-full" label="タグ" messages={errors}>
|
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<TextArea
|
<TextArea
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||||
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
|
|||||||
|
|
||||||
|
|
||||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||||
|
const { label } = useUITranslation ('shared')
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
|
const thumbnailMode = behaviourSettings.thumbnailMode ?? 'normal'
|
||||||
@@ -75,7 +77,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
? (
|
? (
|
||||||
<div className="flex h-full w-full items-center justify-center
|
<div className="flex h-full w-full items-center justify-center
|
||||||
bg-muted text-center text-xs text-muted-foreground">
|
bg-muted text-center text-xs text-muted-foreground">
|
||||||
サムネイルなし
|
{label ('labels.noThumbnail')}
|
||||||
</div>)
|
</div>)
|
||||||
: (
|
: (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||||
import FormField from '@/components/common/FormField'
|
import FormField from '@/components/common/FormField'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
@@ -10,7 +11,7 @@ type Props = {
|
|||||||
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
|
||||||
errors?: string[] }
|
errors?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
const PostOriginalCreatedTimeField: FC<Props> = (
|
const PostOriginalCreatedTimeField: FC<Props> = (
|
||||||
@@ -19,71 +20,71 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
|||||||
setOriginalCreatedFrom,
|
setOriginalCreatedFrom,
|
||||||
originalCreatedBefore,
|
originalCreatedBefore,
|
||||||
setOriginalCreatedBefore,
|
setOriginalCreatedBefore,
|
||||||
errors }: Props) => {
|
errors }: Props,
|
||||||
|
) => {
|
||||||
|
const { label } = useUITranslation ('shared')
|
||||||
return (
|
return (
|
||||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
<FormField label={label ('labels.originalCreatedAt')} messages={errors}>
|
||||||
{({ describedBy, invalid }) => (
|
{({ describedBy, invalid }) => (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 flex">
|
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<div className="w-80">
|
<DateTimeField
|
||||||
<DateTimeField
|
className="min-w-0 max-w-full"
|
||||||
className="mr-2"
|
disabled={disabled ?? false}
|
||||||
disabled={disabled ?? false}
|
aria-describedby={describedBy}
|
||||||
aria-describedby={describedBy}
|
aria-invalid={invalid}
|
||||||
aria-invalid={invalid}
|
invalid={invalid}
|
||||||
invalid={invalid}
|
value={originalCreatedFrom ?? undefined}
|
||||||
value={originalCreatedFrom ?? undefined}
|
onChange={setOriginalCreatedFrom}
|
||||||
onChange={setOriginalCreatedFrom}
|
onBlur={ev => {
|
||||||
onBlur={ev => {
|
const v = ev.target.value
|
||||||
const v = ev.target.value
|
if (!(v))
|
||||||
if (!(v))
|
return
|
||||||
return
|
|
||||||
|
|
||||||
const d = new Date (v)
|
const d = new Date (v)
|
||||||
if (d.getMinutes () === 0 && d.getHours () === 0)
|
if (d.getMinutes () === 0 && d.getHours () === 0)
|
||||||
d.setDate (d.getDate () + 1)
|
d.setDate (d.getDate () + 1)
|
||||||
else
|
else
|
||||||
d.setMinutes (d.getMinutes () + 1)
|
d.setMinutes (d.getMinutes () + 1)
|
||||||
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||||
}}/>
|
}}/>
|
||||||
以降
|
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
|
||||||
</div>
|
{label ('labels.since')}
|
||||||
<div>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className="bg-gray-600 text-white rounded"
|
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
|
||||||
disabled={disabled}
|
dark:bg-gray-600 dark:text-white"
|
||||||
onClick={() => {
|
disabled={disabled}
|
||||||
setOriginalCreatedFrom (null)
|
onClick={() => {
|
||||||
}}>
|
setOriginalCreatedFrom (null)
|
||||||
リセット
|
}}>
|
||||||
</Button>
|
{label ('actions.reset')}
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-1 flex">
|
<div className="my-1 flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<div className="w-80">
|
<DateTimeField
|
||||||
<DateTimeField
|
className="min-w-0 max-w-full"
|
||||||
className="mr-2"
|
disabled={disabled}
|
||||||
disabled={disabled}
|
aria-describedby={describedBy}
|
||||||
aria-describedby={describedBy}
|
aria-invalid={invalid}
|
||||||
aria-invalid={invalid}
|
invalid={invalid}
|
||||||
invalid={invalid}
|
value={originalCreatedBefore ?? undefined}
|
||||||
value={originalCreatedBefore ?? undefined}
|
onChange={setOriginalCreatedBefore}/>
|
||||||
onChange={setOriginalCreatedBefore}/>
|
<span className="ainu-ui-label shrink-0 whitespace-nowrap">
|
||||||
より前
|
{label ('labels.before')}
|
||||||
</div>
|
</span>
|
||||||
<div>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
className="ainu-ui-label shrink-0 rounded bg-gray-600 text-white
|
||||||
className="bg-gray-600 text-white rounded"
|
dark:bg-gray-600 dark:text-white"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOriginalCreatedBefore (null)
|
setOriginalCreatedBefore (null)
|
||||||
}}>
|
}}>
|
||||||
リセット
|
{label ('actions.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
</FormField>)
|
</FormField>)
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { forwardRef, useMemo } from 'react'
|
import { forwardRef, useMemo } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { createPath, useNavigate } from 'react-router-dom'
|
import { createPath } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { localiseTo } from '@/lib/locales'
|
||||||
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||||
import { prefetchForURL } from '@/lib/prefetchers'
|
import { prefetchForURL } from '@/lib/prefetchers'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
@@ -32,13 +34,17 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
if ('onClick' in rest)
|
if ('onClick' in rest)
|
||||||
delete rest['onClick']
|
delete rest['onClick']
|
||||||
|
|
||||||
const navigate = useNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
|
const locale = useLocale ()
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||||
const path = useMemo (
|
const path = useMemo (
|
||||||
() => typeof to === 'string' ? to : createPath (to),
|
() => {
|
||||||
[to],
|
const localised = localiseTo (to, locale)
|
||||||
|
return typeof localised === 'string' ? localised : createPath (localised)
|
||||||
|
},
|
||||||
|
[to, locale],
|
||||||
)
|
)
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
return (new URL (path, window.location.origin)).toString ()
|
return (new URL (path, window.location.origin)).toString ()
|
||||||
@@ -98,7 +104,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
if (!(ok) && cancelOnError)
|
if (!(ok) && cancelOnError)
|
||||||
return
|
return
|
||||||
|
|
||||||
navigate (to, { replace, ...(state && { state }) })
|
navigate (path, { replace, ...(state && { state }) })
|
||||||
}
|
}
|
||||||
catch (ex)
|
catch (ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
type OverlayStore = {
|
type OverlayStore = {
|
||||||
@@ -14,6 +18,9 @@ export const useOverlayStore = create<OverlayStore> (set => ({
|
|||||||
|
|
||||||
|
|
||||||
const RouteBlockerOverlay: FC = () => {
|
const RouteBlockerOverlay: FC = () => {
|
||||||
|
const { i18n } = useTranslation ('common')
|
||||||
|
const loading = translateUIMessage ('loading', 'common', i18n.language)
|
||||||
|
const loadingLabel = translateUIMessage ('loadingLabel', 'common', i18n.language)
|
||||||
const active = useOverlayStore (s => s.active)
|
const active = useOverlayStore (s => s.active)
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -35,14 +42,15 @@ const RouteBlockerOverlay: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-label="Loading"
|
aria-label={loadingLabel.text}
|
||||||
|
lang={loadingLabel.language}
|
||||||
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
|
className="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm pointer-events-auto">
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
|
<div className="rounded-2xl bg-black/60 text-white px-6 py-3 text-sm">
|
||||||
Loading...
|
<LocalisedText language={loading.language}>{loading.text}</LocalisedText>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RouteBlockerOverlay
|
export default RouteBlockerOverlay
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { useLocation } from 'react-router-dom'
|
|||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
|
||||||
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
|
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
|
||||||
by: T
|
by: T
|
||||||
label: string
|
label: ReactNode
|
||||||
currentOrder: `${ T }:${ 'asc' | 'desc' }`
|
currentOrder: `${ T }:${ 'asc' | 'desc' }`
|
||||||
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
|
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
|
||||||
const [fld, dir] = currentOrder.split (':')
|
const [fld, dir] = currentOrder.split (':')
|
||||||
@@ -23,9 +25,9 @@ export default <T extends string,>({ by, label, currentOrder, defaultDirection }
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="text-inherit visited:text-inherit hover:text-inherit"
|
className="text-inherit visited:text-inherit hover:text-inherit"
|
||||||
to={`${ location.pathname }?${ qs.toString () }`}>
|
to={`${ location.pathname }?${ qs.toString () }`}>
|
||||||
<span className="font-bold">
|
<span className="inline-flex w-max items-baseline gap-1 whitespace-nowrap font-bold">
|
||||||
{label}
|
{label}
|
||||||
{by === fld && (dir === 'asc' ? ' ▲' : ' ▼')}
|
{by === fld && <span className="shrink-0">{dir === 'asc' ? '▲' : '▼'}</span>}
|
||||||
</span>
|
</span>
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
@@ -25,8 +26,11 @@ import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
|
|||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
import { localisedDateString, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
|
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
|
||||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||||
|
|
||||||
@@ -180,8 +184,10 @@ const buildFlatTagByCategory = (
|
|||||||
|
|
||||||
const changeCategory = async (
|
const changeCategory = async (
|
||||||
tagId: number,
|
tagId: number,
|
||||||
category: Category): Promise<void> => {
|
category: Category,
|
||||||
await apiPatch (`/tags/${ tagId }`, { category })
|
locale: LocaleCode = 'ja',
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiPatch (`/tags/${ tagId }`, { category }, { locale })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -227,6 +233,8 @@ type ActiveTagDrag = {
|
|||||||
|
|
||||||
|
|
||||||
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { label } = useUITranslation ('tags')
|
||||||
sp = Boolean (sp)
|
sp = Boolean (sp)
|
||||||
|
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
@@ -271,7 +279,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
||||||
|
|
||||||
const reloadTags = async (): Promise<void> => {
|
const reloadTags = async (): Promise<void> => {
|
||||||
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`)))
|
setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`, { locale })))
|
||||||
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
qc.invalidateQueries ({ queryKey: postsKeys.root })
|
||||||
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
qc.invalidateQueries ({ queryKey: tagsKeys.root })
|
||||||
}
|
}
|
||||||
@@ -314,14 +322,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (fromParentId != null)
|
if (fromParentId != null)
|
||||||
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
|
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
|
||||||
|
|
||||||
await apiPost (`/tags/${ parentId }/children/${ childId }`, { })
|
await apiPost (`/tags/${ parentId }/children/${ childId }`, { }, { locale })
|
||||||
|
|
||||||
await reloadTags ()
|
await reloadTags ()
|
||||||
toast ({
|
toast ({
|
||||||
title: '上位タグ対応追加',
|
title: label ('relations.added'),
|
||||||
description: `《${ child?.name }》を《${ parent?.name }》の子タグに設定しました.` })
|
description: label ('relations.addedDescription', {
|
||||||
|
child: child.name, parent: parent.name }) })
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -333,20 +342,21 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
return
|
return
|
||||||
|
|
||||||
if (child.category !== cat)
|
if (child.category !== cat)
|
||||||
await changeCategory (childId, cat)
|
await changeCategory (childId, cat, locale)
|
||||||
|
|
||||||
if (fromParentId != null)
|
if (fromParentId != null)
|
||||||
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
|
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
|
||||||
|
|
||||||
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
|
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
|
||||||
|
|
||||||
await reloadTags ()
|
await reloadTags ()
|
||||||
toast ({
|
toast ({
|
||||||
title: '上位タグ対応解除',
|
title: label ('relations.removed'),
|
||||||
description: (
|
description: (
|
||||||
fromParent
|
fromParent
|
||||||
? `《${ child.name }》を《${ fromParent.name }》の子タグから外しました.`
|
? label ('relations.removedDescription', {
|
||||||
: `《${ child.name }》のカテゴリを変更しました.`) })
|
child: child.name, parent: fromParent.name })
|
||||||
|
: label ('relations.categoryChanged', { name: child.name })) })
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -354,7 +364,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
toast ({ title: '上位タグ対応失敗', description: '独裁者である必要があります.' })
|
toast ({ title: label ('relations.failed'),
|
||||||
|
description: label ('relations.permissionRequired') })
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -468,21 +479,26 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
? undefined
|
? undefined
|
||||||
: `post-info-${ sp }`}
|
: `post-info-${ sp }`}
|
||||||
transition={{ layout: layoutTransition }}>
|
transition={{ layout: layoutTransition }}>
|
||||||
<SectionTitle>情報</SectionTitle>
|
<SectionTitle>{label ('shared:labels.information')}</SectionTitle>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Id.: {post.id}</li>
|
<li>{label ('shared:idPrefix')}{post.id}</li>
|
||||||
<li>
|
<li>
|
||||||
<>耕作者: </>
|
{label ('shared:labels.uploadedBy')}
|
||||||
{post.uploadedUser
|
{post.uploadedUser
|
||||||
? (
|
? (
|
||||||
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
|
<PrefetchLink to={`/users/${ post.uploadedUser.id }`}>
|
||||||
{post.uploadedUser.name || '名もなきニジラー'}
|
{post.uploadedUser.name || label ('shared:labels.anonymous')}
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: 'bot操作'}
|
: label ('shared:labels.bot')}
|
||||||
</li>
|
</li>
|
||||||
<li>耕作日時: {dateString (post.createdAt)}</li>
|
|
||||||
<li>
|
<li>
|
||||||
<>リンク: </>
|
{label ('shared:labels.uploadedAt')}
|
||||||
|
<LocalisedText language={locale}>
|
||||||
|
{localisedDateString (post.createdAt, locale)}
|
||||||
|
</LocalisedText>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{label ('shared:labels.link')}
|
||||||
<a
|
<a
|
||||||
className="break-all"
|
className="break-all"
|
||||||
href={post.url}
|
href={post.url}
|
||||||
@@ -492,13 +508,15 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<>オリジナルの投稿日時: </>
|
{label ('shared:labels.originalPostedAt')}
|
||||||
{originalCreatedAtString (post.originalCreatedFrom,
|
<LocalisedText language={locale}>
|
||||||
post.originalCreatedBefore)}
|
{originalCreatedAtString (post.originalCreatedFrom,
|
||||||
|
post.originalCreatedBefore, locale)}
|
||||||
|
</LocalisedText>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
|
<PrefetchLink to={`/posts/changes?id=${ post.id }`}>
|
||||||
履歴
|
{label ('navigation:history')}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
||||||
@@ -67,6 +68,10 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
style,
|
style,
|
||||||
title,
|
title,
|
||||||
...props }) => {
|
...props }) => {
|
||||||
|
const { message } = useUITranslation ()
|
||||||
|
const missingMaterial = message ('tagLink.missingMaterial', { name: tag.name })
|
||||||
|
const missingDeerjikist = message ('tagLink.missingDeerjikist', { name: tag.name })
|
||||||
|
const missingWiki = message ('tagLink.missingWiki', { name: tag.name })
|
||||||
const colourStyle = {
|
const colourStyle = {
|
||||||
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
|
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
|
||||||
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
|
'--tag-link-hover-colour': `var(--tag-colour-${ tag.category }-hover)`,
|
||||||
@@ -121,7 +126,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
|
to={`/materials/new?tag=${ encodeURIComponent (tag.name) }`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } 素材情報が存在しません.`}>
|
lang={missingMaterial.language}
|
||||||
|
title={missingMaterial.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
@@ -131,7 +137,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/tags/${ tag.id }/deerjikists`}
|
to={`/tags/${ tag.id }/deerjikists`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } に関する情報が存在しません.`}>
|
lang={missingDeerjikist.language}
|
||||||
|
title={missingDeerjikist.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)
|
</PrefetchLink>)
|
||||||
: (
|
: (
|
||||||
@@ -139,7 +146,8 @@ const TagLink: FC<Props> = ({ tag,
|
|||||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||||
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
className="animate-[wiki-blink_.25s_steps(2,end)_infinite]
|
||||||
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]"
|
||||||
title={`${ tag.name } Wiki が存在しません.`}>
|
lang={missingWiki.language}
|
||||||
|
title={missingWiki.text}>
|
||||||
!
|
!
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
</span>)}
|
</span>)}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate, useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -14,8 +16,10 @@ import type { Tag } from '@/types'
|
|||||||
|
|
||||||
|
|
||||||
const TagSearch: FC = () => {
|
const TagSearch: FC = () => {
|
||||||
|
const { message } = useUITranslation ('shared')
|
||||||
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
@@ -32,7 +36,7 @@ const TagSearch: FC = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } })
|
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: { q } })
|
||||||
setSuggestions (data.filter (t => t.postCount > 0))
|
setSuggestions (data.filter (t => t.postCount > 0))
|
||||||
if (suggestions.length > 0)
|
if (suggestions.length > 0)
|
||||||
setSuggestionsVsbl (true)
|
setSuggestionsVsbl (true)
|
||||||
@@ -106,7 +110,7 @@ const TagSearch: FC = () => {
|
|||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
data-shortcut-focus-search="true"
|
data-shortcut-focus-search="true"
|
||||||
placeholder="タグ検索..."
|
placeholder={message ('labels.tagSearch').text}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={whenChanged}
|
onChange={whenChanged}
|
||||||
onFocus={() => setSuggestionsVsbl (true)}
|
onFocus={() => setSuggestionsVsbl (true)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import TagLink from '@/components/TagLink'
|
import TagLink from '@/components/TagLink'
|
||||||
import TagSearch from '@/components/TagSearch'
|
import TagSearch from '@/components/TagSearch'
|
||||||
import SectionTitle from '@/components/common/SectionTitle'
|
import SectionTitle from '@/components/common/SectionTitle'
|
||||||
@@ -22,7 +24,9 @@ type Props = { posts: Post[]
|
|||||||
|
|
||||||
|
|
||||||
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||||
const navigate = useNavigate ()
|
const { label } = useUITranslation ('shared')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const navigate = useLocaleNavigate ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const animationsOff = animationMode === 'off'
|
const animationsOff = animationMode === 'off'
|
||||||
@@ -69,7 +73,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
|
|
||||||
const TagBlock = (
|
const TagBlock = (
|
||||||
<>
|
<>
|
||||||
<SectionTitle>タグ</SectionTitle>
|
<SectionTitle>{label ('navigation:tags')}</SectionTitle>
|
||||||
<ul>
|
<ul>
|
||||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||||
tags[cat].map (tag => (
|
tags[cat].map (tag => (
|
||||||
@@ -88,7 +92,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
</li>))) : [])}
|
</li>))) : [])}
|
||||||
</ul>
|
</ul>
|
||||||
<SectionTitle>関聯</SectionTitle>
|
<SectionTitle>{label ('labels.relations')}</SectionTitle>
|
||||||
<a href="#"
|
<a href="#"
|
||||||
onClick={ev => {
|
onClick={ev => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
@@ -96,7 +100,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
const data = await apiGet<Post> ('/posts/random',
|
const data = await apiGet<Post> ('/posts/random',
|
||||||
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
|
{ locale, params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
|
||||||
match: (anyFlg ? 'any' : 'all') } })
|
match: (anyFlg ? 'any' : 'all') } })
|
||||||
navigate (`/posts/${ data.id }`)
|
navigate (`/posts/${ data.id }`)
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
}
|
}
|
||||||
}) ())
|
}) ())
|
||||||
}}>
|
}}>
|
||||||
ランダム
|
{label ('actions.random')}
|
||||||
</a>
|
</a>
|
||||||
</>)
|
</>)
|
||||||
|
|
||||||
@@ -146,7 +150,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
|||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
setTagsVsbl (v => !(v))
|
setTagsVsbl (v => !(v))
|
||||||
}}>
|
}}>
|
||||||
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
{tagsVsbl ? label ('labels.hideTags') : label ('labels.showTags')}
|
||||||
</a>
|
</a>
|
||||||
</SidebarComponent>)
|
</SidebarComponent>)
|
||||||
}
|
}
|
||||||
|
|||||||
+273
-135
@@ -1,12 +1,22 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { AnimatePresence, motion } from 'framer-motion'
|
import { AnimatePresence, motion } from 'framer-motion'
|
||||||
|
import { Globe } from 'lucide-react'
|
||||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
|
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
||||||
import Separator from '@/components/MenuSeparator'
|
import Separator from '@/components/MenuSeparator'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TopNavUser from '@/components/TopNavUser'
|
import TopNavUser from '@/components/TopNavUser'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
|
||||||
|
import { isLocaleCode,
|
||||||
|
localeFromPath,
|
||||||
|
switchLocalePath,
|
||||||
|
unlocalisedPath } from '@/lib/locales'
|
||||||
|
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
import { materialsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { canEditContent } from '@/lib/users'
|
import { canEditContent } from '@/lib/users'
|
||||||
@@ -17,20 +27,30 @@ import { fetchWikiPage } from '@/lib/wiki'
|
|||||||
|
|
||||||
import type { FC, MouseEvent } from 'react'
|
import type { FC, MouseEvent } from 'react'
|
||||||
|
|
||||||
|
import type { AvailableLocale } from '@/lib/locales'
|
||||||
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
|
||||||
|
|
||||||
type Props = { user: User | null }
|
type Props = { user: User | null; locales: AvailableLocale[] }
|
||||||
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
type NavDensity = 'auto' | 'comfortable' | 'compact'
|
||||||
|
|
||||||
|
|
||||||
export const menuOutline = (
|
export const menuOutline = (
|
||||||
{ tag, material, wikiId, user, pathName }: {
|
{ tag, material, wikiId, user, pathName: fullPathname }: {
|
||||||
tag?: Tag | null
|
tag?: Tag | null
|
||||||
material?: Material | null
|
material?: Material | null
|
||||||
wikiId: number | null
|
wikiId: number | null
|
||||||
user: User | null
|
user: User | null
|
||||||
pathName: string },
|
pathName: string },
|
||||||
): Menu => {
|
): Menu => {
|
||||||
|
const pathName = unlocalisedPath (fullPathname)
|
||||||
|
const locale = localeFromPath (fullPathname) ?? 'ja'
|
||||||
|
|
||||||
|
const message = (
|
||||||
|
key: Parameters<typeof uiMessage>[1],
|
||||||
|
values?: Record<string, string | number>,
|
||||||
|
) => ({ name: uiMessage (locale, key, values),
|
||||||
|
nameLanguage: uiMessageLanguage (locale, key) })
|
||||||
|
|
||||||
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
const postCount = tag?.postCount ?? material?.tag?.postCount ?? 0
|
||||||
const editable = canEditContent (user)
|
const editable = canEditContent (user)
|
||||||
|
|
||||||
@@ -41,71 +61,75 @@ export const menuOutline = (
|
|||||||
|
|
||||||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||||||
|
|
||||||
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
|
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
|
||||||
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: '広場', to: '/posts', subMenu: [
|
{ ...message ('posts'), to: '/posts', subMenu: [
|
||||||
{ name: '一覧', to: '/posts' },
|
{ ...message ('list'), to: '/posts' },
|
||||||
{ name: '検索', to: '/posts/search' },
|
{ ...message ('search'), to: '/posts/search' },
|
||||||
{ name: '追加', to: '/posts/new', visible: editable },
|
{ ...message ('add'), to: '/posts/new', visible: editable },
|
||||||
{ name: '全体履歴', to: '/posts/changes' },
|
{ ...message ('allHistory'), to: '/posts/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
{ ...message ('help'), to: '/wiki/ヘルプ:広場' }] },
|
||||||
{ name: 'タグ', to: '/tags', subMenu: [
|
{ ...message ('tags'), to: '/tags', subMenu: [
|
||||||
{ name: 'マスタ', to: '/tags' },
|
{ ...message ('master'), to: '/tags' },
|
||||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
{ ...message ('nicoLinks'), to: '/tags/nico' },
|
||||||
{ name: '全体履歴', to: '/tags/changes' },
|
{ ...message ('allHistory'), to: '/tags/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
{ ...message ('help'), to: '/wiki/ヘルプ:タグ' },
|
||||||
{ component: <Separator/>, visible: tagFlg },
|
{ component: <Separator/>, visible: tagFlg },
|
||||||
{ name: `広場 (${ postCount || 0 })`,
|
{ ...message ('postsWithCount', { count: postCount }),
|
||||||
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
||||||
visible: tagFlg },
|
visible: tagFlg },
|
||||||
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
|
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
|
||||||
visible: tagFlg && tag?.category !== 'nico' }] },
|
visible: tagFlg && tag?.category !== 'nico' }] },
|
||||||
{ name: '素材', to: '/materials', visible: true, subMenu: [
|
{ ...message ('materials'), to: '/materials', visible: true, subMenu: [
|
||||||
{ name: '一覧', to: '/materials' },
|
{ ...message ('list'), to: '/materials' },
|
||||||
{ name: '追加', to: '/materials/new', visible: editable },
|
{ ...message ('add'), to: '/materials/new', visible: editable },
|
||||||
{ name: '抑止', to: '/materials/suppressions',
|
{ ...message ('suppressions'), to: '/materials/suppressions',
|
||||||
visible: user?.role === 'admin' },
|
visible: user?.role === 'admin' },
|
||||||
{ name: '全体履歴', to: '/materials/changes' },
|
{ ...message ('allHistory'), to: '/materials/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
|
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
|
||||||
{ component: <Separator/>, visible: materialFlg },
|
{ component: <Separator/>, visible: materialFlg },
|
||||||
{ name: `広場 (${ postCount || 0 })`,
|
{ ...message ('postsWithCount', { count: postCount }),
|
||||||
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
|
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
|
||||||
visible: materialFlg && Boolean (material?.tag) },
|
visible: materialFlg && Boolean (material?.tag) },
|
||||||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
||||||
visible: materialFlg }] },
|
visible: materialFlg }] },
|
||||||
{ name: 'Wiki', to: '/wiki', subMenu: [
|
{ ...message ('wiki'), to: '/wiki', subMenu: [
|
||||||
{ name: '検索', to: '/wiki' },
|
{ ...message ('search'), to: '/wiki' },
|
||||||
{ name: '新規', to: '/wiki/new', visible: editable },
|
{ ...message ('create'), to: '/wiki/new', visible: editable },
|
||||||
{ name: '全体履歴', to: '/wiki/changes' },
|
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||||
{ component: <Separator/>, visible: wikiPageFlg },
|
{ component: <Separator/>, visible: wikiPageFlg },
|
||||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
||||||
visible: wikiPageFlg },
|
visible: wikiPageFlg },
|
||||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||||
visible: wikiPageFlg && editable }] },
|
visible: wikiPageFlg && editable }] },
|
||||||
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
|
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
||||||
{ name: '検索', to: '/help' },
|
{ ...message ('search'), to: '/help' },
|
||||||
{ name: '全体履歴', to: '/help/changes' },
|
{ ...message ('allHistory'), to: '/help/changes' },
|
||||||
{ component: <Separator/>, visible: helpVsbl },
|
{ component: <Separator/>, visible: helpVsbl },
|
||||||
{ name: '履歴',
|
{ ...message ('history'),
|
||||||
to: `/help/changes?identifier=${ helpIdentifier }`,
|
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||||
visible: helpVsbl }] },
|
visible: helpVsbl }] },
|
||||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
{ ...message ('entertainment'), visible: false, subMenu: [
|
||||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
{ ...message ('theatre'), to: '/theatres/1' },
|
||||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
{ ...message ('gekanator'), to: '/gekanator' }] },
|
||||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
{ ...message ('users'), to: '/users/settings', visible: false, subMenu: [
|
||||||
{ name: '一覧', to: '/users', visible: false },
|
{ ...message ('list'), to: '/users', visible: false },
|
||||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
{ ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false },
|
||||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
{ ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] },
|
||||||
{ name: '法規', visible: false, subMenu: [
|
{ ...message ('legal'), visible: false, subMenu: [
|
||||||
{ name: '利用規約', to: '/tos' }] }]
|
{ ...message ('terms'), to: '/tos' }] }]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const TopNav: FC<Props> = ({ user }) => {
|
const TopNav: FC<Props> = ({ user, locales }) => {
|
||||||
|
const { message, label } = useUITranslation ('shared')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||||
|
const navigate = useLocaleNavigate ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
@@ -117,23 +141,35 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
const navRef = useRef<HTMLDivElement | null> (null)
|
const navRef = useRef<HTMLDivElement | null> (null)
|
||||||
const layoutRef = useRef<HTMLDivElement | null> (null)
|
const layoutRef = useRef<HTMLDivElement | null> (null)
|
||||||
const barRef = useRef<HTMLElement | null> (null)
|
const barRef = useRef<HTMLElement | null> (null)
|
||||||
|
const submenuContentRef = useRef<HTMLDivElement | null> (null)
|
||||||
|
const highlightTargetRef = useRef (-1)
|
||||||
|
|
||||||
const measure = (idx: number) => {
|
const measure = (idx: number) => {
|
||||||
const nav = navRef.current
|
const nav = navRef.current
|
||||||
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
const el = itemsRef.current[idx < 0 ? visibleMenu.length : idx]
|
||||||
|
|
||||||
if (!(nav) || !(el))
|
if (nav == null || el == null || nav.offsetWidth === 0
|
||||||
{
|
|| layoutRef.current?.dataset.desktop !== 'true')
|
||||||
setHL ({ left: 0, width: 0, visible: true })
|
{
|
||||||
return
|
setHL (current => current.visible
|
||||||
}
|
? { left: 0, width: 0, visible: false } : current)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const navRect = nav.getBoundingClientRect ()
|
// Layout offsets exclude Framer Motion's temporary projection transforms.
|
||||||
const elRect = el.getBoundingClientRect ()
|
let left = 0
|
||||||
|
let node: HTMLElement | null = el
|
||||||
setHL ({ left: elRect.left - navRect.left,
|
while (node != null && node !== nav)
|
||||||
width: elRect.width,
|
{
|
||||||
visible: true })
|
left += node.offsetLeft
|
||||||
|
const parent: Element | null = node.offsetParent
|
||||||
|
node = parent instanceof HTMLElement ? parent : null
|
||||||
|
}
|
||||||
|
const width = node === nav ? el.offsetWidth : 0
|
||||||
|
const visible = width > 0
|
||||||
|
setHL (current =>
|
||||||
|
(current.left === left && current.width === width && current.visible === visible)
|
||||||
|
? current : { left, width, visible })
|
||||||
}
|
}
|
||||||
|
|
||||||
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
|
const [hl, setHL] = useState<{ left: number; width: number; visible: boolean }> ({
|
||||||
@@ -141,62 +177,67 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
width: 0,
|
width: 0,
|
||||||
visible: false })
|
visible: false })
|
||||||
const [menuOpen, setMenuOpen] = useState (false)
|
const [menuOpen, setMenuOpen] = useState (false)
|
||||||
|
const menuToggleLabel = message (menuOpen ? 'dialogue.close' : 'navigation:menuToggle')
|
||||||
const [desktop, setDesktop] = useState (false)
|
const [desktop, setDesktop] = useState (false)
|
||||||
const [density, setDensity] = useState<NavDensity> ('auto')
|
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
|
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||||
|
|
||||||
const wikiIdStr = String (wikiId ?? '')
|
const wikiIdStr = String (wikiId ?? '')
|
||||||
|
|
||||||
const { data: wikiPage } = useQuery ({
|
const { data: wikiPage } = useQuery ({
|
||||||
enabled: Boolean (wikiIdStr),
|
enabled: Boolean (wikiIdStr),
|
||||||
queryKey: wikiKeys.show (wikiIdStr, { }),
|
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
|
||||||
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
|
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
|
||||||
|
|
||||||
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
|
const pathname = unlocalisedPath (location.pathname)
|
||||||
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
|
const tagFlg = /^\/tags\/\d+/.test (pathname)
|
||||||
|
const materialFlg = /^\/materials\/\d+/.test (pathname)
|
||||||
const effectiveTitle = (((tagFlg || materialFlg)
|
const effectiveTitle = (((tagFlg || materialFlg)
|
||||||
? location.pathname.split ('/')[2]
|
? pathname.split ('/')[2]
|
||||||
: wikiPage?.title)
|
: wikiPage?.title)
|
||||||
?? '')
|
?? '')
|
||||||
|
|
||||||
const { data: tag } = useQuery ({
|
const { data: tag } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle),
|
enabled: Boolean (effectiveTitle),
|
||||||
queryKey: tagsKeys.show (effectiveTitle),
|
queryKey: tagsKeys.show (effectiveTitle, locale),
|
||||||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
|
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
|
||||||
|
|
||||||
const { data: material } = useQuery ({
|
const { data: material } = useQuery ({
|
||||||
enabled: Boolean (effectiveTitle),
|
enabled: Boolean (effectiveTitle),
|
||||||
queryKey: materialsKeys.show (effectiveTitle),
|
queryKey: materialsKeys.show (effectiveTitle, locale),
|
||||||
queryFn: () => fetchMaterial (effectiveTitle) })
|
queryFn: () => fetchMaterial (effectiveTitle, locale) })
|
||||||
|
|
||||||
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
||||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||||
|
const menuLabels = visibleMenu.map (item => item.name).join ('\u0000')
|
||||||
const moreMenu = menu.filter (item =>
|
const moreMenu = menu.filter (item =>
|
||||||
!(item.visible ?? true)
|
!(item.visible ?? true)
|
||||||
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
|
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
|
||||||
const activeIdx =
|
const activeIdx =
|
||||||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
|
||||||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
highlightTargetRef.current = moreVsbl ? -1 : activeIdx
|
||||||
|
const activeSubmenuId = `submenu-${ menu.indexOf (visibleMenu[activeIdx]) }`
|
||||||
const topNavTransition =
|
const topNavTransition =
|
||||||
animationsOff
|
animationsOff
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: reducedAnimations
|
: (reducedAnimations
|
||||||
? { duration: .08, ease: 'linear' as const }
|
? { duration: .08, ease: 'linear' as const }
|
||||||
: { duration: .2, ease: 'easeOut' as const }
|
: { duration: .2, ease: 'easeOut' as const })
|
||||||
const opacityTransition =
|
const opacityTransition =
|
||||||
animationsOff
|
animationsOff
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: reducedAnimations
|
: (reducedAnimations
|
||||||
? { duration: .08 }
|
? { duration: .08 }
|
||||||
: { duration: .12 }
|
: { duration: .12 })
|
||||||
const highlightTransitionClass =
|
const highlightTransitionClass =
|
||||||
animationsOff
|
animationsOff
|
||||||
? undefined
|
? undefined
|
||||||
: reducedAnimations
|
: (reducedAnimations
|
||||||
? 'transition-[transform,width] duration-75 ease-linear'
|
? 'transition-[transform,width] duration-75 ease-linear'
|
||||||
: 'transition-[transform,width] duration-200 ease-out'
|
: 'transition-[transform,width] duration-200 ease-out')
|
||||||
|
|
||||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||||
|
|
||||||
@@ -215,7 +256,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
if (layout == null || bar == null || primary == null)
|
if (layout == null || bar == null || primary == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
|
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
|
||||||
let raf = 0
|
let raf = 0
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
@@ -237,11 +278,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
{
|
{
|
||||||
layout.setAttribute ('data-density', candidate)
|
layout.setAttribute ('data-density', candidate)
|
||||||
const style = getComputedStyle (bar)
|
const style = getComputedStyle (bar)
|
||||||
const required = primary.getBoundingClientRect ().width
|
const primaryWidth = Math.max (
|
||||||
+ (userLink?.getBoundingClientRect ().width ?? 0)
|
primary.getBoundingClientRect ().width, primary.scrollWidth)
|
||||||
|
const accountWidth =
|
||||||
|
account == null
|
||||||
|
? 0
|
||||||
|
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth)
|
||||||
|
const required = primaryWidth + accountWidth
|
||||||
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
|
||||||
+ (userLink == null ? 0 : parseFloat (style.columnGap))
|
+ (account == null ? 0 : parseFloat (style.columnGap))
|
||||||
if (required <= bar.clientWidth)
|
if (Math.ceil (required) <= bar.clientWidth)
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -269,8 +315,11 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
||||||
observer?.observe (bar)
|
observer?.observe (bar)
|
||||||
observer?.observe (primary)
|
observer?.observe (primary)
|
||||||
if (userLink != null)
|
for (const item of itemsRef.current)
|
||||||
observer?.observe (userLink)
|
if (item != null)
|
||||||
|
observer?.observe (item)
|
||||||
|
if (account != null)
|
||||||
|
observer?.observe (account)
|
||||||
update ()
|
update ()
|
||||||
addEventListener ('resize', schedule)
|
addEventListener ('resize', schedule)
|
||||||
document.fonts?.addEventListener ('loadingdone', schedule)
|
document.fonts?.addEventListener ('loadingdone', schedule)
|
||||||
@@ -283,22 +332,74 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
removeEventListener ('resize', schedule)
|
removeEventListener ('resize', schedule)
|
||||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
|
||||||
|
|
||||||
|
useLayoutEffect (() => {
|
||||||
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
|
if (!(desktop))
|
||||||
|
setMoreVsbl (false)
|
||||||
|
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
return () => unsubscribe ()
|
return () => unsubscribe ()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useLayoutEffect (() => {
|
||||||
|
const content = submenuContentRef.current
|
||||||
|
if (content == null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
||||||
|
}
|
||||||
|
const observer =
|
||||||
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
||||||
|
observer?.observe (content)
|
||||||
|
update ()
|
||||||
|
addEventListener ('resize', update)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
observer?.disconnect ()
|
||||||
|
removeEventListener ('resize', update)
|
||||||
|
}
|
||||||
|
}, [activeIdx, moreVsbl, desktop, density, locale])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setMenuOpen (false)
|
setMenuOpen (false)
|
||||||
setOpenItemIdx (activeIdx)
|
setOpenItemIdx (activeIdx)
|
||||||
}, [activeIdx, location])
|
}, [activeIdx, location])
|
||||||
|
|
||||||
|
const localeSelector = (
|
||||||
|
<label className="top-nav-menu-link relative flex h-8 shrink-0 items-center gap-1
|
||||||
|
rounded px-1 focus-within:ring-2">
|
||||||
|
<Globe aria-hidden="true" size={16}/>
|
||||||
|
<span className="top-nav-locale-name whitespace-nowrap text-sm">
|
||||||
|
<LocalisedText language={locale}>{localeName}</LocalisedText>
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={formatUIString (localeName ?? '', locale)}
|
||||||
|
value={locale}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
onChange={event => {
|
||||||
|
const nextLocale = event.target.value
|
||||||
|
if (isLocaleCode (nextLocale)
|
||||||
|
&& locales.some (entry => entry.code === nextLocale))
|
||||||
|
{
|
||||||
|
navigate (switchLocalePath (location, nextLocale))
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{locales.map (entry => isLocaleCode (entry.code) && (
|
||||||
|
<option key={entry.code} value={entry.code}>
|
||||||
|
{formatUIString (entry.name, entry.code)}
|
||||||
|
</option>))}
|
||||||
|
</select>
|
||||||
|
</label>)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||||
data-desktop={desktop} data-density={density}>
|
data-desktop={desktop} data-density={density}>
|
||||||
<nav ref={barRef}
|
<nav ref={barRef} lang={uiMessageLanguage (locale, 'posts')}
|
||||||
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
className="top-nav-root top-nav-bar flex justify-between items-center w-full">
|
||||||
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
@@ -307,7 +408,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
scroll (0, 0)
|
scroll (0, 0)
|
||||||
}}>
|
}}>
|
||||||
ぼざクリ タグ広場
|
{label ('labels.brand')}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
|
||||||
<div ref={navRef}
|
<div ref={navRef}
|
||||||
@@ -326,7 +427,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
className="shrink-0 whitespace-nowrap"
|
className="shrink-0 whitespace-nowrap"
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: { layoutId: `menu-${ item.name }` })}
|
: { layoutId: `menu-${ menu.indexOf (item) }` })}
|
||||||
|
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||||
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
animate={{ opacity: moreVsbl ? 0 : 1 }}
|
||||||
transition={{ opacity: opacityTransition,
|
transition={{ opacity: opacityTransition,
|
||||||
layout: topNavTransition }}
|
layout: topNavTransition }}
|
||||||
@@ -339,7 +441,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
}}
|
}}
|
||||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||||
(i === openItemIdx) && 'font-bold')}>
|
(i === openItemIdx) && 'font-bold')}>
|
||||||
{item.name}
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</motion.div>))}
|
</motion.div>))}
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
@@ -355,22 +459,30 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||||
'shrink-0 whitespace-nowrap',
|
'shrink-0 whitespace-nowrap',
|
||||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||||
その他 »
|
<TranslatedText messageKey="more"/>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TopNavUser user={user}/>
|
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
||||||
|
<TopNavUser user={user}/>
|
||||||
|
{localeSelector}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||||
type="button"
|
{menuOpen && localeSelector}
|
||||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
<button
|
||||||
ml-auto border-0 bg-transparent pr-4"
|
type="button"
|
||||||
onClick={() => {
|
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||||
setMenuOpen (!(menuOpen))
|
border-0 bg-transparent pr-4"
|
||||||
}}>
|
aria-label={menuToggleLabel.text}
|
||||||
{menuOpen ? '×' : 'Menu'}
|
lang={menuToggleLabel.language}
|
||||||
</button>
|
onClick={() => {
|
||||||
|
setMenuOpen (!(menuOpen))
|
||||||
|
}}>
|
||||||
|
{menuOpen ? '×' : label ('navigation:menuToggle')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
@@ -387,26 +499,34 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
}}
|
}}
|
||||||
transition={{ layout: topNavTransition }}
|
transition={{ layout: topNavTransition }}
|
||||||
onAnimationComplete={() => {
|
onAnimationComplete={() => {
|
||||||
measure (moreVsbl ? -1 : activeIdx)
|
measure (highlightTargetRef.current)
|
||||||
|
}}
|
||||||
|
onLayoutAnimationComplete={() => {
|
||||||
|
measure (highlightTargetRef.current)
|
||||||
}}>
|
}}>
|
||||||
|
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
||||||
{moreVsbl
|
{moreVsbl
|
||||||
? (
|
? (
|
||||||
moreMenu.map ((item, i) => (
|
moreMenu.map ((item, i) => (
|
||||||
<div key={i} className="relative h-[40px]">
|
<div key={i} className="contents">
|
||||||
<div className="absolute inset-0 flex items-center px-3
|
|
||||||
overflow-x-auto whitespace-nowrap">
|
|
||||||
<motion.div
|
<motion.div
|
||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: ((item.visible ?? true)
|
: ((item.visible ?? true)
|
||||||
? { layoutId: `menu-${ item.name }` }
|
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
||||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||||
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||||
<h2>{item.name}</h2>
|
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||||
|
<h2>
|
||||||
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
|
</h2>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
|
||||||
{item.subMenu
|
{item.subMenu
|
||||||
.filter (subItem => subItem.visible ?? true)
|
.filter (subItem => subItem.visible ?? true)
|
||||||
.map ((subItem, j) => (
|
.map ((subItem, j) => (
|
||||||
@@ -414,12 +534,12 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`c-${ i }-${ j }`}
|
key={`c-${ i }-${ j }`}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: ((visibleMenu[activeIdx]?.name
|
: ((visibleMenu[activeIdx] === item)
|
||||||
=== item.name)
|
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
|
||||||
: { initial: { y: -40, opacity: 0 },
|
: { initial: { y: -40, opacity: 0 },
|
||||||
animate: { y: 0, opacity: 1 },
|
animate: { y: 0, opacity: 1 },
|
||||||
exit: { y: -40, opacity: 0 } }))}>
|
exit: { y: -40, opacity: 0 } }))}>
|
||||||
@@ -428,12 +548,12 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
: (
|
: (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`l-${ i }-${ j }`}
|
key={`l-${ i }-${ j }`}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(animationsOff
|
{...(animationsOff
|
||||||
? { }
|
? { }
|
||||||
: ((visibleMenu[activeIdx]?.name
|
: ((visibleMenu[activeIdx] === item)
|
||||||
=== item.name)
|
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||||
? { layoutId: `submenu-${ item.name }-${ j }` }
|
|
||||||
: { initial: { y: -40, opacity: 0 },
|
: { initial: { y: -40, opacity: 0 },
|
||||||
animate: { y: 0, opacity: 1 },
|
animate: { y: 0, opacity: 1 },
|
||||||
exit: { y: -40, opacity: 0 } }))}>
|
exit: { y: -40, opacity: 0 } }))}>
|
||||||
@@ -441,49 +561,52 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
onClick={() => setMoreVsbl (false)}
|
onClick={() => setMoreVsbl (false)}
|
||||||
className="top-nav-menu-link h-full flex items-center px-3">
|
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
|
||||||
{subItem.name}
|
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
|
||||||
|
{subItem.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</motion.div>)))}
|
</motion.div>)))}
|
||||||
</div>
|
</div>
|
||||||
</div>)))
|
</div>)))
|
||||||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||||
&& (
|
&& (
|
||||||
<div className="relative h-[40px]">
|
<div className="relative min-h-[40px]">
|
||||||
{animationsOff
|
{animationsOff
|
||||||
? (
|
? (
|
||||||
<div className="absolute inset-0 flex items-center px-3
|
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
||||||
overflow-x-auto whitespace-nowrap">
|
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
'component' in item
|
'component' in item
|
||||||
? (
|
? (
|
||||||
<div key={`c-${ i }`}>
|
<div key={`c-${ i }`} className="min-w-0 max-w-full">
|
||||||
{item.component}
|
{item.component}
|
||||||
</div>)
|
</div>)
|
||||||
: (
|
: (
|
||||||
<div key={`l-${ i }`}>
|
<div key={`l-${ i }`} className="min-w-0 max-w-full">
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
target={item.to.slice (0, 2) === '//'
|
target={item.to.slice (0, 2) === '//'
|
||||||
? '_blank'
|
? '_blank'
|
||||||
: undefined}
|
: undefined}
|
||||||
className="top-nav-menu-link h-full flex items-center px-3">
|
className="top-nav-menu-link flex min-h-[40px]
|
||||||
{item.name}
|
items-center px-3 py-2">
|
||||||
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</div>)))}
|
</div>)))}
|
||||||
</div>)
|
</div>)
|
||||||
: (
|
: (
|
||||||
<AnimatePresence initial={false} custom={dir}>
|
<AnimatePresence initial={false} custom={dir} mode="popLayout">
|
||||||
<motion.div
|
<motion.div
|
||||||
key={activeIdx}
|
key={activeIdx}
|
||||||
custom={dir}
|
custom={dir}
|
||||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||||
centre: { y: 0, opacity: 1 },
|
centre: { y: 0, opacity: 1 },
|
||||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||||
className="absolute inset-0 flex items-center px-3
|
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
||||||
overflow-x-auto whitespace-nowrap"
|
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
@@ -495,27 +618,33 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? (
|
? (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`c-${ i }`}
|
key={`c-${ i }`}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
transition={{ layout: topNavTransition }}
|
transition={{ layout: topNavTransition }}
|
||||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
layoutId={`${ activeSubmenuId }-${ i }`}>
|
||||||
{item.component}
|
{item.component}
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
: (
|
: (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`l-${ i }`}
|
key={`l-${ i }`}
|
||||||
|
className="min-w-0 max-w-full"
|
||||||
transition={{ layout: topNavTransition }}
|
transition={{ layout: topNavTransition }}
|
||||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
layoutId={`${ activeSubmenuId }-${ i }`}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={item.to}
|
to={item.to}
|
||||||
target={item.to.slice (0, 2) === '//'
|
target={item.to.slice (0, 2) === '//'
|
||||||
? '_blank'
|
? '_blank'
|
||||||
: undefined}
|
: undefined}
|
||||||
className="top-nav-menu-link h-full flex items-center px-3">
|
className="top-nav-menu-link flex min-h-[40px]
|
||||||
{item.name}
|
items-center px-3 py-2">
|
||||||
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</motion.div>)))}
|
</motion.div>)))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>)}
|
</AnimatePresence>)}
|
||||||
</div>))}
|
</div>))}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -540,7 +669,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
setOpenItemIdx (i)
|
setOpenItemIdx (i)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{item.name}
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
|
||||||
{i === openItemIdx && (
|
{i === openItemIdx && (
|
||||||
@@ -561,7 +692,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? '_blank'
|
? '_blank'
|
||||||
: undefined}
|
: undefined}
|
||||||
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
||||||
{subItem.name}
|
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
|
||||||
|
{subItem.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
</div>)}
|
</div>)}
|
||||||
</Fragment>))}
|
</Fragment>))}
|
||||||
@@ -570,7 +703,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
その他 »
|
<TranslatedText messageKey="more"/>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
@@ -605,7 +738,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
setOpenItemIdx (i)
|
setOpenItemIdx (i)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{item.name}
|
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||||
|
{item.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
|
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
@@ -639,7 +774,10 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
? '_blank'
|
? '_blank'
|
||||||
: undefined}
|
: undefined}
|
||||||
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
|
||||||
{subItem.name}
|
<LocalisedText
|
||||||
|
language={subItem.nameLanguage ?? 'ja'}>
|
||||||
|
{subItem.name}
|
||||||
|
</LocalisedText>
|
||||||
</PrefetchLink>)))}
|
</PrefetchLink>)))}
|
||||||
</motion.div>)}
|
</motion.div>)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
@@ -649,7 +787,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
|||||||
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
|
||||||
((openItemIdx < 0)
|
((openItemIdx < 0)
|
||||||
&& 'top-nav-mobile-active font-bold'))}>
|
&& 'top-nav-mobile-active font-bold'))}>
|
||||||
その他 »
|
<TranslatedText messageKey="more"/>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
<Separator/>
|
<Separator/>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Separator from '@/components/MenuSeparator'
|
import Separator from '@/components/MenuSeparator'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ type Props = { user: User | null,
|
|||||||
|
|
||||||
|
|
||||||
const TopNavUser: FC<Props> = ({ user, sp }) => {
|
const TopNavUser: FC<Props> = ({ user, sp }) => {
|
||||||
|
const { label } = useUITranslation ('shared')
|
||||||
if (!(user))
|
if (!(user))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -23,9 +25,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{sp && <Separator/>}
|
{sp && <Separator/>}
|
||||||
<PrefetchLink to="/users/settings"
|
<PrefetchLink to="/users/settings" className={className}>
|
||||||
className={className}>
|
{user.name || label ('labels.anonymous')}
|
||||||
{user.name || '名もなきニジラー'}
|
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
messageKey: string
|
||||||
|
namespace?: string
|
||||||
|
values?: Record<string, string | number>
|
||||||
|
className?: string }
|
||||||
|
|
||||||
|
|
||||||
|
const TranslatedText = (
|
||||||
|
{ messageKey, namespace = 'navigation', values, className }: Props,
|
||||||
|
) => {
|
||||||
|
const { message } = useUITranslation (namespace)
|
||||||
|
const result = message (messageKey, values)
|
||||||
|
return (
|
||||||
|
<LocalisedText language={result.language} className={className}>
|
||||||
|
{result.text}
|
||||||
|
</LocalisedText>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default TranslatedText
|
||||||
@@ -3,16 +3,20 @@ import { useMemo } from 'react'
|
|||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGFM from 'remark-gfm'
|
import remarkGFM from 'remark-gfm'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import { wikiKeys } from '@/lib/queryKeys'
|
import { wikiKeys } from '@/lib/queryKeys'
|
||||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||||
import { fetchWikiPages } from '@/lib/wiki'
|
import { fetchWikiAutolinkCandidates } from '@/lib/wiki'
|
||||||
|
import { ainuMDXComponents, LocalisedDocument } from '@/mdx-components'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
import type { Components } from 'react-markdown'
|
import type { Components } from 'react-markdown'
|
||||||
|
|
||||||
type Props = { title: string
|
type Props = { title: string
|
||||||
body?: string }
|
body?: string
|
||||||
|
language?: string }
|
||||||
|
|
||||||
const mdComponents = { a: (({ href, children }) => (
|
const mdComponents = { a: (({ href, children }) => (
|
||||||
['/', '.'].some (e => href?.startsWith (e))
|
['/', '.'].some (e => href?.startsWith (e))
|
||||||
@@ -25,20 +29,32 @@ const mdComponents = { a: (({ href, children }) => (
|
|||||||
</a>))) } as const satisfies Components
|
</a>))) } as const satisfies Components
|
||||||
|
|
||||||
|
|
||||||
const WikiBody: FC<Props> = ({ title, body }) => {
|
const WikiBody: FC<Props> = ({ title, body, language = 'und' }) => {
|
||||||
|
const { message } = useUITranslation ('wiki')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const missingPage = message ('missingPage', {
|
||||||
|
url: `/wiki/new?title=${ encodeURIComponent (title) }` })
|
||||||
|
const contentLanguage = body ? language : missingPage.language
|
||||||
|
const matchLanguage = contentLanguage === 'ain-Kana' ? 'ain' : contentLanguage
|
||||||
const { data } = useQuery ({
|
const { data } = useQuery ({
|
||||||
enabled: Boolean (body),
|
enabled: Boolean (body),
|
||||||
queryKey: wikiKeys.index ({ }),
|
queryKey: wikiKeys.autolinkCandidates (matchLanguage, locale),
|
||||||
queryFn: () => fetchWikiPages ({ }) })
|
queryFn: () => fetchWikiAutolinkCandidates (matchLanguage, locale) })
|
||||||
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
|
|
||||||
|
|
||||||
const remarkPlugins = useMemo (
|
const remarkPlugins = useMemo (
|
||||||
() => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames])
|
() => [() => remarkWikiAutoLink (data ?? [], matchLanguage), remarkGFM],
|
||||||
|
[data, matchLanguage])
|
||||||
|
const components = {
|
||||||
|
...((contentLanguage === 'ain' || contentLanguage === 'ain-Kana')
|
||||||
|
? ainuMDXComponents : { }),
|
||||||
|
...mdComponents }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
|
<LocalisedDocument language={contentLanguage}>
|
||||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
<ReactMarkdown components={components} remarkPlugins={remarkPlugins}>
|
||||||
</ReactMarkdown>)
|
{body || missingPage.text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</LocalisedDocument>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WikiBody
|
export default WikiBody
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = { id?: string
|
type Props = { id?: string
|
||||||
messages?: string[] }
|
messages?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
|
export const FieldError: FC<Props> = ({ id, messages }: Props) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FC } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = { id?: string
|
type Props = { id?: string
|
||||||
messages?: string[] }
|
messages?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
|
export const FieldWarning: FC<Props> = ({ id, messages }: Props) => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ type Props = {
|
|||||||
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
|
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void }
|
||||||
className?: string
|
className?: string
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
messages?: string[] }
|
messages?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
|
const FormField: FC<Props> = ({ children, checkBox, className, label, messages }: Props) => {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
type LocalisedTitle = { text: string; language: string }
|
||||||
|
type Props = {
|
||||||
|
title?: string | LocalisedTitle
|
||||||
|
includeSiteName?: boolean
|
||||||
|
children?: ReactNode }
|
||||||
|
|
||||||
|
|
||||||
|
const PageHead = ({ title, includeSiteName = true, children }: Props) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { message, siteTitle } = useUITranslation ('shared')
|
||||||
|
const pageTitle = typeof title === 'string' ? { text: title, language: locale } : title
|
||||||
|
const fullTitle =
|
||||||
|
pageTitle == null
|
||||||
|
? siteTitle.text
|
||||||
|
: (includeSiteName ? `${pageTitle.text} | ${siteTitle.text}` : pageTitle.text)
|
||||||
|
const description = message ('head.description', { brand: siteTitle.text })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Helmet>
|
||||||
|
<title lang={pageTitle?.language ?? siteTitle.language}>{fullTitle}</title>
|
||||||
|
<meta name="description" content={description.text} lang={description.language}/>
|
||||||
|
{children}
|
||||||
|
</Helmet>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default PageHead
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const getPages = (
|
|||||||
|
|
||||||
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
|
const { message } = useUITranslation ()
|
||||||
|
|
||||||
const buildTo = (p: number) => {
|
const buildTo = (p: number) => {
|
||||||
const qs = new URLSearchParams (location.search)
|
const qs = new URLSearchParams (location.search)
|
||||||
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
const pages = getPages (page, totalPages, siblingCount)
|
const pages = getPages (page, totalPages, siblingCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="mt-4 flex justify-center" aria-label="Pagination">
|
<nav className="mt-4 flex justify-center"
|
||||||
|
lang={message ('pagination.label').language}
|
||||||
|
aria-label={message ('pagination.label').text}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(page > 1)
|
{(page > 1)
|
||||||
? (
|
? (
|
||||||
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="md:hidden p-2"
|
className="md:hidden p-2"
|
||||||
to={buildTo (1)}
|
to={buildTo (1)}
|
||||||
aria-label="最初のページ">
|
lang={message ('pagination.first').language}
|
||||||
|
aria-label={message ('pagination.first').text}>
|
||||||
|<
|
|<
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="p-2"
|
className="p-2"
|
||||||
to={buildTo (page - 1)}
|
to={buildTo (page - 1)}
|
||||||
aria-label="前のページ">
|
lang={message ('pagination.previous').language}
|
||||||
|
aria-label={message ('pagination.previous').text}>
|
||||||
<
|
<
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
|||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="p-2"
|
className="p-2"
|
||||||
to={buildTo (page + 1)}
|
to={buildTo (page + 1)}
|
||||||
aria-label="次のページ">
|
lang={message ('pagination.next').language}
|
||||||
|
aria-label={message ('pagination.next').text}>
|
||||||
>
|
>
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
className="md:hidden p-2"
|
className="md:hidden p-2"
|
||||||
to={buildTo (totalPages)}
|
to={buildTo (totalPages)}
|
||||||
aria-label="最後のページ">
|
lang={message ('pagination.last').language}
|
||||||
|
aria-label={message ('pagination.last').text}>
|
||||||
>|
|
>|
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>)
|
</>)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { FC } from 'react'
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
type TabProps = { name: string
|
type TabProps = { name: React.ReactNode
|
||||||
init?: boolean
|
init?: boolean
|
||||||
children: React.ReactNode }
|
children: React.ReactNode }
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import TagSearchBox from '@/components/TagSearchBox'
|
import TagSearchBox from '@/components/TagSearchBox'
|
||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
import { inputClass } from '@/lib/utils'
|
import { inputClass } from '@/lib/utils'
|
||||||
@@ -24,6 +25,7 @@ const TagInput: FC<Props> = ({
|
|||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
}) => {
|
}) => {
|
||||||
|
const locale = useLocale ()
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||||
@@ -39,7 +41,7 @@ const TagInput: FC<Props> = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
||||||
q,
|
q,
|
||||||
nico: includeNico ? '1' : '0' } })
|
nico: includeNico ? '1' : '0' } })
|
||||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Dialog,
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle } from '@/components/ui/dialog'
|
DialogTitle } from '@/components/ui/dialog'
|
||||||
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
import type { AlertOptions,
|
import type { AlertOptions,
|
||||||
@@ -42,6 +43,7 @@ type Props = { children: ReactNode }
|
|||||||
|
|
||||||
|
|
||||||
const DialogueProvider: FC<Props> = ({ children }) => {
|
const DialogueProvider: FC<Props> = ({ children }) => {
|
||||||
|
const { label } = useUITranslation ('shared')
|
||||||
const [queue, setQueue] = useState<DialogueRequest[]> ([])
|
const [queue, setQueue] = useState<DialogueRequest[]> ([])
|
||||||
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
||||||
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
||||||
@@ -88,8 +90,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
})
|
})
|
||||||
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
||||||
setFormActions (current => {
|
setFormActions (current => {
|
||||||
const { [id]: _, ...rest } = current
|
const rest = { ...current }
|
||||||
return rest
|
delete rest[id]
|
||||||
|
return rest
|
||||||
})
|
})
|
||||||
delete formControls.current[id]
|
delete formControls.current[id]
|
||||||
}, [])
|
}, [])
|
||||||
@@ -178,6 +181,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
closeRequest (active.id, active.kind !== 'confirm' && null)
|
closeRequest (active.id, active.kind !== 'confirm' && null)
|
||||||
}}>
|
}}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
|
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
|
||||||
|
? active.options.closeLabel : undefined}
|
||||||
className={
|
className={
|
||||||
active.kind === 'form'
|
active.kind === 'form'
|
||||||
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
|
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
|
||||||
@@ -217,9 +222,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
className="shrink-0 flex-col gap-2 pt-4
|
className="shrink-0 flex-col gap-2 pt-4
|
||||||
md:flex-row md:justify-between md:gap-0 md:space-x-0">
|
md:flex-row md:justify-between md:gap-0 md:space-x-0">
|
||||||
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
||||||
{startActions.map (action => (
|
{startActions.map ((action, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
key={index}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
variant={action.variant === 'danger'
|
variant={action.variant === 'danger'
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
@@ -239,12 +244,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
onClick={() => closeRequest (active.id)}
|
onClick={() => closeRequest (active.id)}
|
||||||
disabled={pendingIds.includes (active.id)
|
disabled={pendingIds.includes (active.id)
|
||||||
|| nestedConfirm?.parentId === active.id}>
|
|| nestedConfirm?.parentId === active.id}>
|
||||||
{active.options.cancelText ?? '取消'}
|
{active.options.cancelText ?? label ('actions.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{endActions.map (action => (
|
{endActions.map ((action, index) => (
|
||||||
<Button
|
<Button
|
||||||
key={action.label}
|
key={index}
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
variant={action.variant === 'danger'
|
variant={action.variant === 'danger'
|
||||||
? 'destructive'
|
? 'destructive'
|
||||||
@@ -275,7 +280,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeRequest (active.id, false)}>
|
onClick={() => closeRequest (active.id, false)}>
|
||||||
{active.options.cancelText ?? '取消'}
|
{active.options.cancelText ?? label ('actions.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -283,13 +288,13 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'default'}
|
: 'default'}
|
||||||
onClick={() => closeRequest (active.id, true)}>
|
onClick={() => closeRequest (active.id, true)}>
|
||||||
{active.options.confirmText ?? '確定'}
|
{active.options.confirmText ?? label ('dialogue.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</>)}
|
</>)}
|
||||||
|
|
||||||
{active.kind === 'alert' && (
|
{active.kind === 'alert' && (
|
||||||
<Button onClick={() => closeRequest (active.id)}>
|
<Button onClick={() => closeRequest (active.id)}>
|
||||||
{active.options.okText ?? '確定'}
|
{active.options.okText ?? label ('dialogue.confirm')}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
|
|
||||||
{active.kind === 'choice' && (
|
{active.kind === 'choice' && (
|
||||||
@@ -297,7 +302,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeRequest (active.id, null)}>
|
onClick={() => closeRequest (active.id, null)}>
|
||||||
{active.options.cancelText ?? '取消'}
|
{active.options.cancelText ?? label ('actions.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{active.options.choices.map (choice => (
|
{active.options.choices.map (choice => (
|
||||||
@@ -322,7 +327,8 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
if (!(open))
|
if (!(open))
|
||||||
closeNestedConfirm (false)
|
closeNestedConfirm (false)
|
||||||
}}>
|
}}>
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
<DialogContent className="px-6 pb-6 pt-7"
|
||||||
|
closeLabel={nestedConfirm.options.closeLabel}>
|
||||||
<DialogHeader className="pl-8">
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
|
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
|
||||||
|
|
||||||
@@ -336,7 +342,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => closeNestedConfirm (false)}>
|
onClick={() => closeNestedConfirm (false)}>
|
||||||
{nestedConfirm.options.cancelText ?? '取消'}
|
{nestedConfirm.options.cancelText ?? label ('actions.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -344,7 +350,7 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
|||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'default'}
|
: 'default'}
|
||||||
onClick={() => closeNestedConfirm (true)}>
|
onClick={() => closeNestedConfirm (true)}>
|
||||||
{nestedConfirm.options.confirmText ?? '確定'}
|
{nestedConfirm.options.confirmText ?? label ('dialogue.confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async'
|
|||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
@@ -70,6 +71,7 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
sidebarKey,
|
sidebarKey,
|
||||||
side = 'left',
|
side = 'left',
|
||||||
}) => {
|
}) => {
|
||||||
|
const { message } = useUITranslation ('shared')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
@@ -341,7 +343,8 @@ const SidebarComponent: FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation="vertical"
|
aria-orientation="vertical"
|
||||||
aria-label="サイドバー幅"
|
aria-label={message ('sidebarWidth').text}
|
||||||
|
lang={message ('sidebarWidth').language}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerEnd}
|
onPointerUp={handlePointerEnd}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||||
import PostTagsField from '@/components/posts/PostTagsField'
|
import PostTagsField from '@/components/posts/PostTagsField'
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type TextMessages = string[] | undefined
|
type TextMessages = ReactNode[] | undefined
|
||||||
|
|
||||||
type CoreField = {
|
type CoreField = {
|
||||||
value: string
|
value: string
|
||||||
@@ -42,7 +43,7 @@ type PostCoreDataFieldsProps = {
|
|||||||
rows?: number }
|
rows?: number }
|
||||||
parentPostIds: CoreField }
|
parentPostIds: CoreField }
|
||||||
|
|
||||||
const groupedMessages = (...values: (TextMessages | null | undefined)[]): string[] =>
|
const groupedMessages = (...values: (TextMessages | null | undefined)[]): ReactNode[] =>
|
||||||
[...new Set (values.flatMap (value => value ?? []))]
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
|||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="タイトル"
|
label={<TranslatedText namespace="shared" messageKey="labels.title"/>}
|
||||||
value={title.value}
|
value={title.value}
|
||||||
disabled={title.disabled}
|
disabled={title.disabled}
|
||||||
warnings={title.warnings}
|
warnings={title.warnings}
|
||||||
@@ -82,7 +83,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
|||||||
rows={tags.rows}/>
|
rows={tags.rows}/>
|
||||||
|
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="親投稿"
|
label={<TranslatedText namespace="shared" messageKey="labels.parentPosts"/>}
|
||||||
value={parentPostIds.value}
|
value={parentPostIds.value}
|
||||||
disabled={parentPostIds.disabled}
|
disabled={parentPostIds.disabled}
|
||||||
warnings={parentPostIds.warnings}
|
warnings={parentPostIds.warnings}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
|
import PostCoreDataFields from '@/components/posts/PostCoreDataFields'
|
||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
|
import type { PostCoreDataFieldsProps } from '@/components/posts/PostCoreDataFields'
|
||||||
|
|
||||||
type TextMessages = string[] | undefined
|
type TextMessages = ReactNode[] | undefined
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
url: {
|
url: {
|
||||||
@@ -29,7 +30,7 @@ const PostCreationDataFields: FC<Props> = (
|
|||||||
) => (
|
) => (
|
||||||
<>
|
<>
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="URL"
|
label={<TranslatedText namespace="shared" messageKey="url"/>}
|
||||||
type={url.type}
|
type={url.type}
|
||||||
value={url.value}
|
value={url.value}
|
||||||
disabled={url.disabled}
|
disabled={url.disabled}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
errors?: string[]
|
errors?: ReactNode[]
|
||||||
disabled?: boolean }
|
disabled?: boolean }
|
||||||
|
|
||||||
|
|
||||||
@@ -16,12 +17,11 @@ const PostDurationField: FC<Props> = (
|
|||||||
disabled },
|
disabled },
|
||||||
) => (
|
) => (
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="動画時間"
|
label={<TranslatedText namespace="shared" messageKey="labels.duration"/>}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
type="text"/>
|
type="text"/>)
|
||||||
)
|
|
||||||
|
|
||||||
export default PostDurationField
|
export default PostDurationField
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
import PostFormTagsArea from '@/components/PostFormTagsArea'
|
||||||
import FieldWarning from '@/components/common/FieldWarning'
|
import FieldWarning from '@/components/common/FieldWarning'
|
||||||
|
|
||||||
import type { ComponentPropsWithoutRef, FC } from 'react'
|
import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
|
type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange'> & {
|
||||||
tags: string
|
tags: string
|
||||||
setTags: (tags: string) => void
|
setTags: (tags: string) => void
|
||||||
warnings?: string[]
|
warnings?: ReactNode[]
|
||||||
errors?: string[] }
|
errors?: ReactNode[] }
|
||||||
|
|
||||||
|
|
||||||
const PostTagsField: FC<Props> = (
|
const PostTagsField: FC<Props> = (
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { inputClass } from '@/lib/utils'
|
|||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: string
|
label: ReactNode
|
||||||
value: string
|
value: string
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
warnings?: string[]
|
warnings?: ReactNode[]
|
||||||
errors?: string[]
|
errors?: ReactNode[]
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
type?: string
|
type?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -15,10 +16,11 @@ type Props = {
|
|||||||
const PostThumbnailPreview: FC<Props> = (
|
const PostThumbnailPreview: FC<Props> = (
|
||||||
{ url,
|
{ url,
|
||||||
file,
|
file,
|
||||||
alt = 'サムネール',
|
alt,
|
||||||
className = 'h-16 w-16',
|
className = 'h-16 w-16',
|
||||||
referrerPolicy },
|
referrerPolicy },
|
||||||
) => {
|
) => {
|
||||||
|
const { message } = useUITranslation ('shared')
|
||||||
const [failed, setFailed] = useState (false)
|
const [failed, setFailed] = useState (false)
|
||||||
const [fileUrl, setFileUrl] = useState<string | null> (null)
|
const [fileUrl, setFileUrl] = useState<string | null> (null)
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ const PostThumbnailPreview: FC<Props> = (
|
|||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={resolvedUrl}
|
src={resolvedUrl}
|
||||||
alt={alt}
|
alt={alt ?? message ('labels.thumbnail').text}
|
||||||
referrerPolicy={referrerPolicy}
|
referrerPolicy={referrerPolicy}
|
||||||
className={cn (className, 'rounded border border-border object-cover')}
|
className={cn (className, 'rounded border border-border object-cover')}
|
||||||
onError={() => setFailed (true)}/>)
|
onError={() => setFailed (true)}/>)
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import PostDurationField from '@/components/posts/PostDurationField'
|
|||||||
import PostTextField from '@/components/posts/PostTextField'
|
import PostTextField from '@/components/posts/PostTextField'
|
||||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
import { hasThumbnailBaseValue, hasVideoTag } from '@/lib/postImportRows'
|
||||||
|
import { renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
|
|
||||||
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
import type { DialogueFormControls } from '@/lib/dialogues/useDialogue'
|
||||||
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
|
import type { PostImportEditableDraft, PostImportRow } from '@/lib/postImportTypes'
|
||||||
@@ -47,9 +50,6 @@ const buildResetDraft = (row: PostImportRow): Draft => ({
|
|||||||
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
duration: String (row.resetSnapshot.attributes.duration ?? ''),
|
||||||
thumbnailFile: undefined })
|
thumbnailFile: undefined })
|
||||||
|
|
||||||
const groupedMessages = (...values: (string[] | undefined)[]): string[] =>
|
|
||||||
[...new Set (values.flatMap (value => value ?? []))]
|
|
||||||
|
|
||||||
const sameDraft = (left: Draft, right: Draft): boolean =>
|
const sameDraft = (left: Draft, right: Draft): boolean =>
|
||||||
left.url === right.url
|
left.url === right.url
|
||||||
&& left.title === right.title
|
&& left.title === right.title
|
||||||
@@ -98,6 +98,11 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
controls,
|
controls,
|
||||||
onSave },
|
onSave },
|
||||||
) => {
|
) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { label } = useUITranslation ('imports')
|
||||||
|
const groupedMessages = (...values: (string[] | undefined)[]): ReactNode[] =>
|
||||||
|
[...new Set (values.flatMap (value => value ?? []))]
|
||||||
|
.map (value => renderPostImportMessage (value, locale))
|
||||||
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
const [draft, setDraft] = useState<Draft> (() => buildDraft (row))
|
||||||
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
const [messageRow, setMessageRow] = useState<PostImportRow | null> (null)
|
||||||
const [saving, setSaving] = useState (false)
|
const [saving, setSaving] = useState (false)
|
||||||
@@ -144,9 +149,9 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
return false
|
return false
|
||||||
|
|
||||||
const confirmed = await controls.confirm ({
|
const confirmed = await controls.confirm ({
|
||||||
title: '変更をリセットしますか?',
|
title: label ('shared:messages.resetChanges'),
|
||||||
confirmText: 'リセット',
|
confirmText: label ('shared:actions.reset'),
|
||||||
cancelText: '取消',
|
cancelText: label ('shared:actions.cancel'),
|
||||||
variant: 'danger' })
|
variant: 'danger' })
|
||||||
if (!(confirmed))
|
if (!(confirmed))
|
||||||
return false
|
return false
|
||||||
@@ -156,7 +161,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
setMessageRow (null)
|
setMessageRow (null)
|
||||||
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
setCommittedThumbnailBase (resetDraft.thumbnailBase)
|
||||||
return false
|
return false
|
||||||
}, [controls, resetDisabled, resetDraft])
|
}, [controls, resetDisabled, resetDraft, label])
|
||||||
|
|
||||||
const save = useCallback (async (): Promise<boolean> => {
|
const save = useCallback (async (): Promise<boolean> => {
|
||||||
setSaving (true)
|
setSaving (true)
|
||||||
@@ -178,16 +183,16 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
controls.setActions ([{
|
controls.setActions ([{
|
||||||
label: '変更をリセット',
|
label: label ('review.reset'),
|
||||||
placement: 'start',
|
placement: 'start',
|
||||||
variant: 'danger',
|
variant: 'danger',
|
||||||
disabled: resetDisabled,
|
disabled: resetDisabled,
|
||||||
onSelect: reset },
|
onSelect: reset },
|
||||||
{
|
{
|
||||||
label: '編輯内容を保存',
|
label: label ('review.save'),
|
||||||
disabled: saving,
|
disabled: saving,
|
||||||
onSelect: save }])
|
onSelect: save }])
|
||||||
}, [controls, resetDisabled, reset, save, saving])
|
}, [controls, resetDisabled, reset, save, saving, label])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -210,17 +215,17 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
value: draft.url,
|
value: draft.url,
|
||||||
onChange: value => update ('url', value),
|
onChange: value => update ('url', value),
|
||||||
disabled: saving,
|
disabled: saving,
|
||||||
warnings: displayRow.fieldWarnings.url,
|
warnings: groupedMessages (displayRow.fieldWarnings.url),
|
||||||
errors: groupedMessages (
|
errors: groupedMessages (
|
||||||
displayRow.validationErrors.url,
|
displayRow.validationErrors.url,
|
||||||
displayRow.importErrors?.url) }}
|
displayRow.importErrors?.url) }}
|
||||||
thumbnailField={
|
thumbnailField={
|
||||||
<>
|
<>
|
||||||
<PostTextField
|
<PostTextField
|
||||||
label="サムネール"
|
label={label ('shared:labels.thumbnail')}
|
||||||
value={draft.thumbnailBase}
|
value={draft.thumbnailBase}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
warnings={currentThumbnailWarnings}
|
warnings={groupedMessages (currentThumbnailWarnings)}
|
||||||
errors={groupedMessages (
|
errors={groupedMessages (
|
||||||
displayRow.validationErrors.thumbnailBase,
|
displayRow.validationErrors.thumbnailBase,
|
||||||
displayRow.importErrors?.thumbnailBase)}
|
displayRow.importErrors?.thumbnailBase)}
|
||||||
@@ -244,7 +249,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
value: draft.title,
|
value: draft.title,
|
||||||
onChange: value => update ('title', value),
|
onChange: value => update ('title', value),
|
||||||
disabled: saving,
|
disabled: saving,
|
||||||
warnings: displayRow.fieldWarnings.title,
|
warnings: groupedMessages (displayRow.fieldWarnings.title),
|
||||||
errors: groupedMessages (
|
errors: groupedMessages (
|
||||||
displayRow.validationErrors.title,
|
displayRow.validationErrors.title,
|
||||||
displayRow.importErrors?.title) },
|
displayRow.importErrors?.title) },
|
||||||
@@ -270,7 +275,7 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
value: draft.tags,
|
value: draft.tags,
|
||||||
onChange: value => update ('tags', value),
|
onChange: value => update ('tags', value),
|
||||||
disabled: saving,
|
disabled: saving,
|
||||||
warnings: displayRow.fieldWarnings.tags,
|
warnings: groupedMessages (displayRow.fieldWarnings.tags),
|
||||||
errors: groupedMessages (
|
errors: groupedMessages (
|
||||||
displayRow.validationErrors.tags,
|
displayRow.validationErrors.tags,
|
||||||
displayRow.importErrors?.tags),
|
displayRow.importErrors?.tags),
|
||||||
@@ -293,9 +298,9 @@ const PostImportRowForm: FC<Props> = (
|
|||||||
displayRow.validationErrors.videoMs,
|
displayRow.validationErrors.videoMs,
|
||||||
displayRow.importErrors?.videoMs)}/>)
|
displayRow.importErrors?.videoMs)}/>)
|
||||||
: null}/>
|
: null}/>
|
||||||
<FieldWarning messages={displayRow.baseWarnings}/>
|
<FieldWarning messages={groupedMessages (displayRow.baseWarnings)}/>
|
||||||
<FieldError messages={displayRow.validationErrors.base}/>
|
<FieldError messages={groupedMessages (displayRow.validationErrors.base)}/>
|
||||||
<FieldError messages={displayRow.importErrors?.base}/>
|
<FieldError messages={groupedMessages (displayRow.importErrors?.base)}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import PostImportTagLinks from '@/components/posts/import/PostImportTagLinks'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
import PostImportThumbnailPreview from '@/components/posts/import/PostImportThumbnailPreview'
|
||||||
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
import PostImportStatusBadge from '@/components/posts/import/PostImportStatusBadge'
|
||||||
import { displayPostImportStatus } from '@/components/posts/import/postImportRowStatus'
|
import { displayPostImportStatus,
|
||||||
|
renderPostImportMessage } from '@/components/posts/import/postImportRowStatus'
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import {
|
import {
|
||||||
canEditReviewRow,
|
canEditReviewRow,
|
||||||
canRetryResultRow,
|
canRetryResultRow,
|
||||||
@@ -14,6 +18,7 @@ import { cn, originalCreatedAtString } from '@/lib/utils'
|
|||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||||
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
row: PostImportRow
|
row: PostImportRow
|
||||||
@@ -33,10 +38,10 @@ const summaryWarning = (row: PostImportRow): string | null =>
|
|||||||
?? row.baseWarnings?.[0]
|
?? row.baseWarnings?.[0]
|
||||||
?? null
|
?? null
|
||||||
|
|
||||||
const summaryDate = (row: PostImportRow): string =>
|
const summaryDate = (row: PostImportRow, locale: LocaleCode): string =>
|
||||||
originalCreatedAtString (
|
originalCreatedAtString (
|
||||||
row.attributes.originalCreatedFrom?.toString () ?? null,
|
row.attributes.originalCreatedFrom?.toString () ?? null,
|
||||||
row.attributes.originalCreatedBefore?.toString () ?? null)
|
row.attributes.originalCreatedBefore?.toString () ?? null, locale)
|
||||||
|
|
||||||
|
|
||||||
const PostImportRowSummary: FC<Props> = (
|
const PostImportRowSummary: FC<Props> = (
|
||||||
@@ -52,6 +57,10 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
showActions = true,
|
showActions = true,
|
||||||
showSkipToggle = false },
|
showSkipToggle = false },
|
||||||
) => {
|
) => {
|
||||||
|
const locale = useLocale ()
|
||||||
|
const { label } = useUITranslation ('imports')
|
||||||
|
const localisedRowMessages = rowMessages?.map (value =>
|
||||||
|
renderPostImportMessage (value, locale))
|
||||||
const warning = summaryWarning (row)
|
const warning = summaryWarning (row)
|
||||||
const displayStatus = displayPostImportStatus (row)
|
const displayStatus = displayPostImportStatus (row)
|
||||||
const editVisible = onEdit != null
|
const editVisible = onEdit != null
|
||||||
@@ -69,7 +78,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
checked={skipChecked}
|
checked={skipChecked}
|
||||||
onChange={event => onToggleSkip?.(event.target.checked)}
|
onChange={event => onToggleSkip?.(event.target.checked)}
|
||||||
disabled={skipDisabled === true}/>
|
disabled={skipDisabled === true}/>
|
||||||
<span>スキップ</span>
|
<span>{label ('shared:actions.skip')}</span>
|
||||||
</label>)
|
</label>)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
@@ -96,17 +105,17 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
</div>
|
</div>
|
||||||
<PostImportTagLinks tags={row.displayTags}/>
|
<PostImportTagLinks tags={row.displayTags}/>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||||
</div>
|
</div>
|
||||||
{showDuration && (
|
{showDuration && (
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
動画時間 {duration}
|
{label ('review.duration', { duration })}
|
||||||
</div>)}
|
</div>)}
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{renderPostImportMessage (warning, locale)}
|
||||||
</div>)}
|
</div>)}
|
||||||
<FieldError messages={rowMessages}/>
|
<FieldError messages={localisedRowMessages}/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
{displayStatus != null && <PostImportStatusBadge value={displayStatus}/>}
|
||||||
@@ -120,7 +129,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={editDisabled === true || !(editAllowed)}>
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
編輯
|
{label ('shared:actions.edit')}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{showActions && retryAllowed && (
|
{showActions && retryAllowed && (
|
||||||
<Button
|
<Button
|
||||||
@@ -128,7 +137,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
disabled={retryDisabled === true}>
|
disabled={retryDisabled === true}>
|
||||||
再試行
|
{label ('shared:actions.retry')}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,17 +165,17 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
</div>
|
</div>
|
||||||
<PostImportTagLinks tags={row.displayTags}/>
|
<PostImportTagLinks tags={row.displayTags}/>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
{summaryDate (row)}
|
<LocalisedText language={locale}>{summaryDate (row, locale)}</LocalisedText>
|
||||||
</div>
|
</div>
|
||||||
{showDuration && (
|
{showDuration && (
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
動画時間 {duration}
|
{label ('review.duration', { duration })}
|
||||||
</div>)}
|
</div>)}
|
||||||
{warning && (
|
{warning && (
|
||||||
<div className="text-xs text-amber-700 dark:text-amber-200">
|
<div className="text-xs text-amber-700 dark:text-amber-200">
|
||||||
{warning}
|
{renderPostImportMessage (warning, locale)}
|
||||||
</div>)}
|
</div>)}
|
||||||
<FieldError messages={rowMessages}/>
|
<FieldError messages={localisedRowMessages}/>
|
||||||
{skipControl}
|
{skipControl}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,7 +188,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
disabled={editDisabled === true || !(editAllowed)}>
|
disabled={editDisabled === true || !(editAllowed)}>
|
||||||
編輯
|
{label ('shared:actions.edit')}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
{retryAllowed && (
|
{retryAllowed && (
|
||||||
<Button
|
<Button
|
||||||
@@ -188,7 +197,7 @@ const PostImportRowSummary: FC<Props> = (
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onRetry}
|
onClick={onRetry}
|
||||||
disabled={retryDisabled === true}>
|
disabled={retryDisabled === true}>
|
||||||
再試行
|
{label ('shared:actions.retry')}
|
||||||
</Button>)}
|
</Button>)}
|
||||||
</div>)}
|
</div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import StatusBadge from '@/components/common/StatusBadge'
|
import StatusBadge from '@/components/common/StatusBadge'
|
||||||
|
import TranslatedText from '@/components/TranslatedText'
|
||||||
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
|
|||||||
type Props = {
|
type Props = {
|
||||||
value: PostImportBadgeValue }
|
value: PostImportBadgeValue }
|
||||||
|
|
||||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
|
||||||
ready: '登録可能',
|
|
||||||
error: '登録不可',
|
|
||||||
warning: '警告',
|
|
||||||
skipped: 'スキップ',
|
|
||||||
created: '登録済み',
|
|
||||||
failed: '登録失敗' }
|
|
||||||
|
|
||||||
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||||
ready: 'success',
|
ready: 'success',
|
||||||
error: 'warning',
|
error: 'warning',
|
||||||
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
|||||||
|
|
||||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||||
<StatusBadge tone={TONES[value]}>
|
<StatusBadge tone={TONES[value]}>
|
||||||
{LABELS[value]}
|
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
|
||||||
</StatusBadge>)
|
</StatusBadge>)
|
||||||
|
|
||||||
export default PostImportStatusBadge
|
export default PostImportStatusBadge
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
const PostImportThumbnailPreview: FC<Props> = (
|
const PostImportThumbnailPreview: FC<Props> = (
|
||||||
{ url,
|
{ url,
|
||||||
file,
|
file,
|
||||||
alt = 'サムネール',
|
alt,
|
||||||
className = 'h-16 w-16' },
|
className = 'h-16 w-16' },
|
||||||
) => (
|
) => (
|
||||||
<PostThumbnailPreview
|
<PostThumbnailPreview
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { createElement } from 'react'
|
||||||
|
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { i18n, translateUIMessage } from '@/lib/i18n'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import type { PostImportRow } from '@/lib/postImportTypes'
|
import type { PostImportRow } from '@/lib/postImportTypes'
|
||||||
|
|
||||||
export type PostImportDisplayStatus =
|
export type PostImportDisplayStatus =
|
||||||
@@ -10,6 +17,40 @@ export type PostImportDisplayStatus =
|
|||||||
|
|
||||||
export type PostImportBadgeValue = PostImportDisplayStatus
|
export type PostImportBadgeValue = PostImportDisplayStatus
|
||||||
|
|
||||||
|
const IMPORT_MESSAGE_KEYS = [
|
||||||
|
'validation.duplicateUrl', 'validation.processingFailed',
|
||||||
|
'validation.tooMany', 'validation.urlTooLong', 'validation.invalidUrl',
|
||||||
|
'validation.protocol', 'validation.duplicateRow', 'storage.readFailed',
|
||||||
|
'storage.writeFailed', 'storage.clearFailed', 'source.urlRequired',
|
||||||
|
'shared:labels.thumbnailMissing']
|
||||||
|
|
||||||
|
|
||||||
|
// Keep stored and compared messages intact; translate only recognised UI messages at display time.
|
||||||
|
export const renderPostImportMessage = (value: string, locale: string): ReactNode => {
|
||||||
|
for (const reference of IMPORT_MESSAGE_KEYS)
|
||||||
|
{
|
||||||
|
const [namespace, key] =
|
||||||
|
reference.includes (':')
|
||||||
|
? reference.split (':')
|
||||||
|
: ['imports', reference]
|
||||||
|
const template: unknown = i18n.getResource ('ja', namespace, key)
|
||||||
|
if (typeof template !== 'string')
|
||||||
|
continue
|
||||||
|
const names = [...template.matchAll (/\{\{(\w+)\}\}/g)].map (match => match[1])
|
||||||
|
const pattern = template.split (/(\{\{\w+\}\})/).map (part =>
|
||||||
|
part.startsWith ('{{')
|
||||||
|
? '(\\d+)'
|
||||||
|
: part.replace (/[.*+?^${}()|[\]\\]/g, '\\$&')).join ('')
|
||||||
|
const match = value.match (new RegExp (`^${pattern}$`))
|
||||||
|
if (match == null)
|
||||||
|
continue
|
||||||
|
const values = Object.fromEntries (names.map ((name, index) => [name, match[index + 1]]))
|
||||||
|
const result = translateUIMessage (key, namespace, locale, values)
|
||||||
|
return createElement (LocalisedText, { language: result.language, children: result.text })
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
const hasWarnings = (row: PostImportRow): boolean =>
|
const hasWarnings = (row: PostImportRow): boolean =>
|
||||||
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
Object.values (row.fieldWarnings ?? { }).some (messages => messages.length > 0)
|
||||||
|| row.baseWarnings.length > 0
|
|| row.baseWarnings.length > 0
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const Dialog = DialogPrimitive.Root
|
const Dialog = DialogPrimitive.Root
|
||||||
@@ -32,37 +35,45 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
|||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
& { closeLabel?: React.ReactNode }
|
||||||
>(
|
>(
|
||||||
({ className, children, ...props }, ref) => (
|
({ className, children, closeLabel, ...props }, ref) => {
|
||||||
<DialogPortal>
|
const { i18n } = useTranslation ('shared')
|
||||||
<DialogOverlay />
|
const close = translateUIMessage ('dialogue.close', 'shared', i18n.language)
|
||||||
<DialogPrimitive.Content
|
return (
|
||||||
ref={ref}
|
<DialogPortal>
|
||||||
className={cn (
|
<DialogOverlay/>
|
||||||
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
|
<DialogPrimitive.Content
|
||||||
'translate-x-[-50%] translate-y-[-50%]',
|
ref={ref}
|
||||||
'gap-5 rounded-2xl border border-border',
|
|
||||||
'bg-background p-6 text-foreground shadow-2xl',
|
|
||||||
'duration-200',
|
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
|
||||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
|
||||||
className)}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
className={cn (
|
className={cn (
|
||||||
'absolute left-4 top-4 rounded-full p-1',
|
'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg',
|
||||||
'text-slate-500 transition-colors',
|
'translate-x-[-50%] translate-y-[-50%]',
|
||||||
'hover:bg-slate-200 hover:text-slate-900',
|
'gap-5 rounded-2xl border border-border',
|
||||||
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
'bg-background p-6 text-foreground shadow-2xl',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
'duration-200',
|
||||||
<X className="h-4 w-4"/>
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
<span className="sr-only">閉ぢる</span>
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
</DialogPrimitive.Close>
|
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
</DialogPrimitive.Content>
|
className)}
|
||||||
</DialogPortal>))
|
{...props}>
|
||||||
|
{children}
|
||||||
|
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
className={cn (
|
||||||
|
'absolute left-4 top-4 rounded-full p-1',
|
||||||
|
'text-slate-500 transition-colors',
|
||||||
|
'hover:bg-slate-200 hover:text-slate-900',
|
||||||
|
'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-slate-400')}>
|
||||||
|
<X className="h-4 w-4"/>
|
||||||
|
<span className="sr-only">
|
||||||
|
{closeLabel ?? (
|
||||||
|
<LocalisedText language={close.language}>{close.text}</LocalisedText>)}
|
||||||
|
</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>)
|
||||||
|
})
|
||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = (
|
const DialogHeader = (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
|
|||||||
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 { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
|
import { getClientBehaviorSettings, setClientBehaviorSettings } from '@/lib/settings'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -22,7 +23,7 @@ type Props = {
|
|||||||
|
|
||||||
type SegmentedOption<T extends string> = {
|
type SegmentedOption<T extends string> = {
|
||||||
value: T
|
value: T
|
||||||
label: string }
|
label: ReactNode }
|
||||||
|
|
||||||
type SegmentedControlProps<T extends string> = {
|
type SegmentedControlProps<T extends string> = {
|
||||||
value: T
|
value: T
|
||||||
@@ -30,27 +31,18 @@ type SegmentedControlProps<T extends string> = {
|
|||||||
onChange: (value: T) => void }
|
onChange: (value: T) => void }
|
||||||
|
|
||||||
type SettingBlockProps = {
|
type SettingBlockProps = {
|
||||||
title: string
|
title: ReactNode
|
||||||
description?: string
|
description?: ReactNode
|
||||||
children: ReactNode }
|
children: ReactNode }
|
||||||
|
|
||||||
const animationOptions: SegmentedOption<ClientAnimationMode>[] = [
|
const animationOptions: ClientAnimationMode[] = ['off', 'reduced', 'normal']
|
||||||
{ value: 'off', label: 'なし' },
|
|
||||||
{ value: 'reduced', label: '控えめ' },
|
|
||||||
{ value: 'normal', label: '通常' }]
|
|
||||||
|
|
||||||
const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
|
const embedAutoLoadOptions: ClientEmbedAutoLoadMode[] = ['off', 'manual', 'auto']
|
||||||
{ value: 'off', label: '読み込まない' },
|
|
||||||
{ value: 'manual', label: 'クリック時' },
|
|
||||||
{ value: 'auto', label: '自動' }]
|
|
||||||
|
|
||||||
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
|
const linkPreloadOptions: ClientLinkPreloadMode[] = ['off', 'intent']
|
||||||
{ value: 'off', label: 'しない' },
|
|
||||||
{ value: 'intent', label: 'する' }]
|
const tagRelationDisplayOptions: ClientTagRelationDisplayMode[] = ['flat', 'grouped']
|
||||||
|
|
||||||
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
|
|
||||||
{ value: 'flat', label: 'まとめない' },
|
|
||||||
{ value: 'grouped', label: 'まとめる' }]
|
|
||||||
|
|
||||||
const SegmentedControl = <T extends string,> (
|
const SegmentedControl = <T extends string,> (
|
||||||
{ value, options, onChange }: SegmentedControlProps<T>,
|
{ value, options, onChange }: SegmentedControlProps<T>,
|
||||||
@@ -92,10 +84,9 @@ const SettingBlock: FC<SettingBlockProps> = (
|
|||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
const BehaviourSettingsSection: FC<Props> = (
|
const BehaviourSettingsSection: FC<Props> = props => {
|
||||||
{ sectionClassName,
|
const { sectionClassName, onDirtyStateChange } = props
|
||||||
onDirtyStateChange },
|
const { label } = useUITranslation ('settings')
|
||||||
) => {
|
|
||||||
const [savedSettings, setSavedSettings] =
|
const [savedSettings, setSavedSettings] =
|
||||||
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
|
||||||
const [draftSettings, setDraftSettings] =
|
const [draftSettings, setDraftSettings] =
|
||||||
@@ -122,7 +113,7 @@ const BehaviourSettingsSection: FC<Props> = (
|
|||||||
const next = setClientBehaviorSettings (draftSettings)
|
const next = setClientBehaviorSettings (draftSettings)
|
||||||
setSavedSettings (next)
|
setSavedSettings (next)
|
||||||
setDraftSettings (next)
|
setDraftSettings (next)
|
||||||
toast ({ title: '動作設定を保存しました' })
|
toast ({ title: label ('behavior.saved') })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
@@ -141,7 +132,7 @@ const BehaviourSettingsSection: FC<Props> = (
|
|||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-bold">動作</h2>
|
<h2 className="text-xl font-bold">{label ('tabs.behavior')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -150,45 +141,49 @@ const BehaviourSettingsSection: FC<Props> = (
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={hasUnsavedChanges === false}
|
disabled={hasUnsavedChanges === false}
|
||||||
onClick={handleDiscard}>
|
onClick={handleDiscard}>
|
||||||
変更を破棄
|
{label ('actions.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={hasUnsavedChanges === false}
|
disabled={hasUnsavedChanges === false}
|
||||||
onClick={handleSave}>
|
onClick={handleSave}>
|
||||||
保存
|
{label ('actions.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingBlock title="アニメーション">
|
<SettingBlock title={label ('behavior.animation')}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={draftSettings.animation ?? 'normal'}
|
value={draftSettings.animation ?? 'normal'}
|
||||||
options={animationOptions}
|
options={animationOptions.map (value => ({ value,
|
||||||
|
label: label (`behavior.animationOptions.${value}`) }))}
|
||||||
onChange={value => updateDraft ('animation', value)}/>
|
onChange={value => updateDraft ('animation', value)}/>
|
||||||
</SettingBlock>
|
</SettingBlock>
|
||||||
|
|
||||||
<SettingBlock
|
<SettingBlock
|
||||||
title="リンク先の先読み"
|
title={label ('behavior.preload')}
|
||||||
description="マウスを置いた時点でリンク先のデータを取得します。">
|
description={label ('behavior.preloadDescription')}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={draftSettings.linkPreload ?? 'intent'}
|
value={draftSettings.linkPreload ?? 'intent'}
|
||||||
options={linkPreloadOptions}
|
options={linkPreloadOptions.map (value => ({ value,
|
||||||
|
label: label (`behavior.preloadOptions.${value}`) }))}
|
||||||
onChange={value => updateDraft ('linkPreload', value)}/>
|
onChange={value => updateDraft ('linkPreload', value)}/>
|
||||||
</SettingBlock>
|
</SettingBlock>
|
||||||
|
|
||||||
<SettingBlock title="タグの親子関係表示">
|
<SettingBlock title={label ('behavior.tagRelations')}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={draftSettings.tagRelationDisplay ?? 'grouped'}
|
value={draftSettings.tagRelationDisplay ?? 'grouped'}
|
||||||
options={tagRelationDisplayOptions}
|
options={tagRelationDisplayOptions.map (value => ({ value,
|
||||||
|
label: label (`behavior.relationOptions.${value}`) }))}
|
||||||
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
|
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
|
||||||
</SettingBlock>
|
</SettingBlock>
|
||||||
|
|
||||||
<SettingBlock title="埋め込み自動読込">
|
<SettingBlock title={label ('behavior.embeds')}>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
value={draftSettings.embedAutoLoad ?? 'auto'}
|
value={draftSettings.embedAutoLoad ?? 'auto'}
|
||||||
options={embedAutoLoadOptions}
|
options={embedAutoLoadOptions.map (value => ({ value,
|
||||||
|
label: label (`behavior.embedOptions.${value}`) }))}
|
||||||
onChange={value => updateDraft ('embedAutoLoad', value)}/>
|
onChange={value => updateDraft ('embedAutoLoad', value)}/>
|
||||||
</SettingBlock>
|
</SettingBlock>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -12,6 +10,9 @@ import { 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'
|
||||||
import { apiPost } from '@/lib/api'
|
import { apiPost } from '@/lib/api'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
@@ -22,14 +23,17 @@ type Props = { visible: boolean
|
|||||||
|
|
||||||
const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
|
const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const { message, label } = useUITranslation ('settings')
|
||||||
|
|
||||||
const [inputCode, setInputCode] = useState ('')
|
const [inputCode, setInputCode] = useState ('')
|
||||||
|
|
||||||
const handleTransfer = async () => {
|
const handleTransfer = async () => {
|
||||||
if (!(await dialogue.confirm ({
|
if (!(await dialogue.confirm ({
|
||||||
title: '引継ぎを行ってもよろしいですか?',
|
title: label ('transfer.confirm'),
|
||||||
description: '現在のアカウントからはログアウトされます.',
|
description: label ('transfer.logoutWarning'),
|
||||||
confirmText: '引継ぐ',
|
confirmText: label ('transfer.submit'),
|
||||||
|
cancelText: label ('actions.cancel'),
|
||||||
|
closeLabel: label ('actions.close'),
|
||||||
variant: 'danger' })))
|
variant: 'danger' })))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -41,29 +45,31 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
|
|||||||
{
|
{
|
||||||
localStorage.setItem ('user_code', inputCode)
|
localStorage.setItem ('user_code', inputCode)
|
||||||
setUser (data.user)
|
setUser (data.user)
|
||||||
toast ({ title: '引継ぎ成功!' })
|
toast ({ title: label ('transfer.success') })
|
||||||
onVisibleChange (false)
|
onVisibleChange (false)
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
toast ({ title: '認証失敗', description: 'そのコードは使へません.' })
|
toast ({ title: label ('transfer.authFailed'), description: label ('transfer.invalidCode') })
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
toast ({ title: '通信エラー', description: 'またあとで試してね.' })
|
toast ({ title: label ('transfer.networkError'), description: label ('transfer.retryLater') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||||
<DialogContent className="px-6 pp-6 pt-7">
|
<DialogContent className="px-6 pp-6 pt-7" closeLabel={label ('actions.close')}>
|
||||||
<DialogHeader className="pl-8">
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>ほかのブラウザから引継ぐ</DialogTitle>
|
<DialogTitle>{label ('account.inherit')}</DialogTitle>
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input placeholder="引継ぎコードを入力"
|
<Input placeholder={message ('transfer.placeholder').text}
|
||||||
|
aria-label={message ('transfer.placeholder').text}
|
||||||
|
lang={message ('transfer.placeholder').language}
|
||||||
value={inputCode}
|
value={inputCode}
|
||||||
onChange={ev => setInputCode (ev.target.value)}/>
|
onChange={ev => setInputCode (ev.target.value)}/>
|
||||||
<Button onClick={handleTransfer}>引継ぐ</Button>
|
<Button onClick={handleTransfer}>{label ('transfer.submit')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@@ -8,14 +8,15 @@ import {
|
|||||||
isSpaceBinding,
|
isSpaceBinding,
|
||||||
keyBindingFromKeyboardEvent,
|
keyBindingFromKeyboardEvent,
|
||||||
SHORTCUT_DEFINITIONS,
|
SHORTCUT_DEFINITIONS,
|
||||||
SHORTCUT_SCOPE_LABELS,
|
|
||||||
} from '@/lib/keyboardShortcuts'
|
} from '@/lib/keyboardShortcuts'
|
||||||
import { getEffectiveKeyBindings } from '@/lib/settings'
|
import { getEffectiveKeyBindings } from '@/lib/settings'
|
||||||
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
import { useKeyboardShortcutSettings } from '@/lib/useKeyboardShortcuts'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
|
import type { ShortcutActionId } from '@/lib/keyboardShortcuts'
|
||||||
import type { ClientKeyboardSettings } from '@/lib/settings'
|
import type { ClientKeyboardSettings } from '@/lib/settings'
|
||||||
import type { FC } from 'react'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sectionClassName: string
|
sectionClassName: string
|
||||||
@@ -26,10 +27,9 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const KeyboardSettingsSection: FC<Props> = (
|
const KeyboardSettingsSection: FC<Props> = props => {
|
||||||
{ sectionClassName,
|
const { sectionClassName, onDirtyStateChange } = props
|
||||||
onDirtyStateChange },
|
const { label } = useUITranslation ('settings')
|
||||||
) => {
|
|
||||||
const {
|
const {
|
||||||
keyboardSettings,
|
keyboardSettings,
|
||||||
saveKeyboardSettings,
|
saveKeyboardSettings,
|
||||||
@@ -92,7 +92,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
|
|
||||||
if (isSpaceBinding (binding))
|
if (isSpaceBinding (binding))
|
||||||
{
|
{
|
||||||
toast ({ title: 'Space は今は割り当てられません.' })
|
toast ({ title: label ('shortcuts.spaceUnavailable') })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
|
|
||||||
document.addEventListener ('keydown', onKeyDown, true)
|
document.addEventListener ('keydown', onKeyDown, true)
|
||||||
return () => document.removeEventListener ('keydown', onKeyDown, true)
|
return () => document.removeEventListener ('keydown', onKeyDown, true)
|
||||||
}, [capturingActionId])
|
}, [capturingActionId, label])
|
||||||
|
|
||||||
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
|
const rows = useMemo (() => SHORTCUT_DEFINITIONS.map (definition => ({
|
||||||
...definition,
|
...definition,
|
||||||
@@ -126,7 +126,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
saveKeyboardSettings (draftKeyboardSettings)
|
saveKeyboardSettings (draftKeyboardSettings)
|
||||||
toast ({ title: 'キーボード設定を保存しました' })
|
toast ({ title: label ('shortcuts.saved') })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDiscard = () => {
|
const handleDiscard = () => {
|
||||||
@@ -146,7 +146,7 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
<section className={sectionClassName}>
|
<section className={sectionClassName}>
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="text-xl font-bold">キーボード</h2>
|
<h2 className="text-xl font-bold">{label ('tabs.keyboard')}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -159,20 +159,20 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
bindings: { },
|
bindings: { },
|
||||||
}))
|
}))
|
||||||
}}>
|
}}>
|
||||||
すべて既定に戻す
|
{label ('actions.resetAll')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
disabled={!(hasUnsavedChanges)}
|
disabled={!(hasUnsavedChanges)}
|
||||||
onClick={handleDiscard}>
|
onClick={handleDiscard}>
|
||||||
変更を破棄
|
{label ('actions.discard')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
|
disabled={!(hasUnsavedChanges) || draftConflictActionIds.size > 0}
|
||||||
onClick={handleSave}>
|
onClick={handleSave}>
|
||||||
保存
|
{label ('actions.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -187,33 +187,35 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
enabled: event.target.checked,
|
enabled: event.target.checked,
|
||||||
}))
|
}))
|
||||||
}}/>
|
}}/>
|
||||||
<span>キーボード・ショートカットを有効にする</span>
|
<span>{label ('shortcuts.enable')}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{capturingActionId != null && (
|
{capturingActionId != null && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
キー入力待ちです.Escape で取消し,Backspace / Delete で未割当にできます.
|
{label ('shortcuts.captureHelp')}
|
||||||
</p>)}
|
</p>)}
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
|
<table className="w-full min-w-[52rem] table-fixed border-collapse text-sm">
|
||||||
<thead className="border-b border-border">
|
<thead className="border-b border-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 text-left">操作</th>
|
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
|
||||||
<th className="p-2 text-left">適用範囲</th>
|
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
|
||||||
<th className="p-2 text-left">現在のキー</th>
|
<th className="p-2 text-left">{label ('shortcuts.currentKey')}</th>
|
||||||
<th className="p-2 text-left"></th>
|
<th className="p-2 text-left"></th>
|
||||||
<th className="p-2 text-left"></th>
|
<th className="p-2 text-left"></th>
|
||||||
<th className="p-2 text-left">競合状態</th>
|
<th className="p-2 text-left">{label ('shortcuts.conflictState')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map (row => (
|
{rows.map (row => (
|
||||||
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||||
<td className="p-2">{row.label}</td>
|
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
|
||||||
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
|
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
|
||||||
<td className="p-2 font-mono">
|
<td className="p-2 font-mono">
|
||||||
{formatKeyBinding (row.currentBinding)}
|
{row.currentBinding == null
|
||||||
|
? label ('shortcuts.unassigned')
|
||||||
|
: <span lang="en">{formatKeyBinding (row.currentBinding)}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -225,7 +227,8 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
capturingActionId === row.id ? null : row.id,
|
capturingActionId === row.id ? null : row.id,
|
||||||
)
|
)
|
||||||
}}>
|
}}>
|
||||||
{capturingActionId === row.id ? '入力待ち…' : '変更'}
|
{label (capturingActionId === row.id
|
||||||
|
? 'shortcuts.capturing' : 'actions.change')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
@@ -244,13 +247,15 @@ const KeyboardSettingsSection: FC<Props> = (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}>
|
}}>
|
||||||
既定に戻す
|
{label ('actions.reset')}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{row.hasConflict
|
{row.hasConflict
|
||||||
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
? <span className="text-red-600 dark:text-red-400">
|
||||||
: 'なし'}
|
{label ('shortcuts.conflicting')}
|
||||||
|
</span>
|
||||||
|
: label ('shortcuts.none')}
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -5,19 +5,13 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import {
|
import { formatKeyBinding, SHORTCUT_DEFINITIONS } from '@/lib/keyboardShortcuts'
|
||||||
formatKeyBinding,
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
SHORTCUT_DEFINITIONS,
|
|
||||||
SHORTCUT_SCOPE_LABELS,
|
|
||||||
} from '@/lib/keyboardShortcuts'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
KeyBinding,
|
|
||||||
ShortcutActionId,
|
|
||||||
ShortcutScope,
|
|
||||||
} from '@/lib/keyboardShortcuts'
|
|
||||||
import type { FC } from 'react'
|
import type { FC } from 'react'
|
||||||
|
|
||||||
|
import type { KeyBinding, ShortcutActionId, ShortcutScope } from '@/lib/keyboardShortcuts'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
@@ -28,16 +22,10 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const ShortcutHelpDialog: FC<Props> = (
|
const ShortcutHelpDialog: FC<Props> = props => {
|
||||||
{
|
const { open, onOpenChange, activeScope, effectiveBindings,
|
||||||
open,
|
conflictActionIds, availableActionIds } = props
|
||||||
onOpenChange,
|
const { label } = useUITranslation ('settings')
|
||||||
activeScope,
|
|
||||||
effectiveBindings,
|
|
||||||
conflictActionIds,
|
|
||||||
availableActionIds,
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
const rows = SHORTCUT_DEFINITIONS
|
const rows = SHORTCUT_DEFINITIONS
|
||||||
.filter (definition =>
|
.filter (definition =>
|
||||||
definition.scope === 'global' || definition.scope === activeScope)
|
definition.scope === 'global' || definition.scope === activeScope)
|
||||||
@@ -45,11 +33,12 @@ const ShortcutHelpDialog: FC<Props> = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-3xl px-6 pb-6 pt-7">
|
<DialogContent className="max-w-3xl px-6 pb-6 pt-7"
|
||||||
|
closeLabel={label ('actions.close')}>
|
||||||
<DialogHeader className="pl-8">
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>キーボード・ショートカット</DialogTitle>
|
<DialogTitle>{label ('shortcuts.title')}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
この画面で利用できるショートカットを表示します。
|
{label ('shortcuts.description')}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -57,24 +46,28 @@ const ShortcutHelpDialog: FC<Props> = (
|
|||||||
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
|
<table className="w-full min-w-[40rem] table-fixed border-collapse text-sm">
|
||||||
<thead className="border-b border-border">
|
<thead className="border-b border-border">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="p-2 text-left">操作</th>
|
<th className="p-2 text-left">{label ('shortcuts.operation')}</th>
|
||||||
<th className="p-2 text-left">適用範囲</th>
|
<th className="p-2 text-left">{label ('shortcuts.scope')}</th>
|
||||||
<th className="p-2 text-left">キー</th>
|
<th className="p-2 text-left">{label ('shortcuts.key')}</th>
|
||||||
<th className="p-2 text-left">競合</th>
|
<th className="p-2 text-left">{label ('shortcuts.conflict')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map (row => (
|
{rows.map (row => (
|
||||||
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
<tr key={row.id} className="border-b border-border/60 last:border-b-0">
|
||||||
<td className="p-2">{row.label}</td>
|
<td className="p-2">{label (`shortcuts.actions.${row.id}`)}</td>
|
||||||
<td className="p-2">{SHORTCUT_SCOPE_LABELS[row.scope]}</td>
|
<td className="p-2">{label (`shortcuts.scopes.${row.scope}`)}</td>
|
||||||
<td className="p-2 font-mono">
|
<td className="p-2 font-mono">
|
||||||
{formatKeyBinding (effectiveBindings[row.id])}
|
{effectiveBindings[row.id] == null
|
||||||
|
? label ('shortcuts.unassigned')
|
||||||
|
: <span lang="en">{formatKeyBinding (effectiveBindings[row.id])}</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
{conflictActionIds.has (row.id)
|
{conflictActionIds.has (row.id)
|
||||||
? <span className="text-red-600 dark:text-red-400">競合あり</span>
|
? <span className="text-red-600 dark:text-red-400">
|
||||||
: 'なし'}
|
{label ('shortcuts.conflicting')}
|
||||||
|
</span>
|
||||||
|
: label ('shortcuts.none')}
|
||||||
</td>
|
</td>
|
||||||
</tr>))}
|
</tr>))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { FC } from 'react'
|
import { useDialogue } from '@/lib/dialogues/useDialogue'
|
||||||
|
|
||||||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog,
|
import { Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,6 +8,9 @@ import { Dialog,
|
|||||||
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'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { FC } from 'react'
|
||||||
|
|
||||||
import type { User } from '@/types'
|
import type { User } from '@/types'
|
||||||
|
|
||||||
@@ -21,18 +22,21 @@ type Props = { visible: boolean
|
|||||||
|
|
||||||
const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => {
|
const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }) => {
|
||||||
const dialogue = useDialogue ()
|
const dialogue = useDialogue ()
|
||||||
|
const { label } = useUITranslation ('settings')
|
||||||
|
|
||||||
const handleChange = async () => {
|
const handleChange = async () => {
|
||||||
if (!(user))
|
if (!(user))
|
||||||
return
|
return
|
||||||
|
|
||||||
if (!(await dialogue.confirm ({
|
if (!(await dialogue.confirm ({
|
||||||
title: '引継ぎコードを再発行しますか?',
|
title: label ('userCode.confirm'),
|
||||||
description: (
|
description: (
|
||||||
<div>
|
<div>
|
||||||
<p>再発行するとほかのブラウザからはログアウトされます.</p>
|
<p>{label ('userCode.logoutWarning')}</p>
|
||||||
</div>),
|
</div>),
|
||||||
confirmText: '再発行',
|
confirmText: label ('userCode.confirmRenew'),
|
||||||
|
cancelText: label ('actions.cancel'),
|
||||||
|
closeLabel: label ('actions.close'),
|
||||||
variant: 'danger' })))
|
variant: 'danger' })))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -42,22 +46,22 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
|
|||||||
{
|
{
|
||||||
localStorage.setItem ('user_code', data.code)
|
localStorage.setItem ('user_code', data.code)
|
||||||
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
|
setUser (user => ({ ...user, inheritanceCode: data.code } as User))
|
||||||
toast ({ title: '再発行しました.' })
|
toast ({ title: label ('userCode.renewed') })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
<Dialog open={visible} onOpenChange={onVisibleChange}>
|
||||||
<DialogContent className="px-6 pb-6 pt-7">
|
<DialogContent className="px-6 pb-6 pt-7" closeLabel={label ('actions.close')}>
|
||||||
<DialogHeader className="pl-8">
|
<DialogHeader className="pl-8">
|
||||||
<DialogTitle>引継ぎコード</DialogTitle>
|
<DialogTitle>{label ('userCode.title')}</DialogTitle>
|
||||||
|
|
||||||
<DialogDescription asChild>
|
<DialogDescription asChild>
|
||||||
<div>
|
<div>
|
||||||
<p>あなたの引継ぎコードはこちらです:</p>
|
<p>{label ('userCode.description')}</p>
|
||||||
<div className="m-2">{user?.inheritanceCode}</div>
|
<div className="m-2">{user?.inheritanceCode}</div>
|
||||||
<p className="mt-1 text-sm text-destructive">
|
<p className="mt-1 text-sm text-destructive">
|
||||||
このコードはほかの人には教えないでください!
|
{label ('userCode.secretWarning')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
@@ -65,7 +69,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={handleChange} variant="destructive">
|
<Button onClick={handleChange} variant="destructive">
|
||||||
引継ぎコード再発行
|
{label ('userCode.renew')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
const ENV: string = 'development'
|
const ENV: string = 'development'
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002',
|
API_BASE_URL: ENV === 'production' ? 'https://hub.nizika.monster/api' : 'http://localhost:3002' }
|
||||||
SITE_TITLE: 'ぼざクリ タグ広場'}
|
|
||||||
|
|
||||||
export const API_BASE_URL = config.API_BASE_URL
|
export const API_BASE_URL = config.API_BASE_URL
|
||||||
export const SITE_TITLE = config.SITE_TITLE
|
|
||||||
|
|||||||
+118
-1
@@ -4,6 +4,45 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
.ainu-ui-text
|
||||||
|
{
|
||||||
|
white-space: normal !important;
|
||||||
|
word-break: keep-all !important;
|
||||||
|
overflow-wrap: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ainu-ui-word
|
||||||
|
{
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
word-break: keep-all !important;
|
||||||
|
overflow-wrap: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ainu-ui-label .ainu-ui-text
|
||||||
|
{
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ainu-ui-label wbr
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.localised-document
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.localised-document a:lang(ain)
|
||||||
|
{
|
||||||
|
white-space: nowrap;
|
||||||
|
word-break: keep-all;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base
|
@layer base
|
||||||
{
|
{
|
||||||
:root
|
:root
|
||||||
@@ -208,6 +247,12 @@ body
|
|||||||
column-gap: 4px;
|
column-gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-account
|
||||||
|
{
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
.top-nav-layout .top-nav-brand-link
|
.top-nav-layout .top-nav-brand-link
|
||||||
{
|
{
|
||||||
margin-inline: 4px;
|
margin-inline: 4px;
|
||||||
@@ -216,6 +261,7 @@ body
|
|||||||
|
|
||||||
.top-nav-desktop-menu,
|
.top-nav-desktop-menu,
|
||||||
.top-nav-desktop-submenu,
|
.top-nav-desktop-submenu,
|
||||||
|
.top-nav-layout .top-nav-account,
|
||||||
.top-nav-user-desktop
|
.top-nav-user-desktop
|
||||||
{
|
{
|
||||||
display: none;
|
display: none;
|
||||||
@@ -226,6 +272,39 @@ body
|
|||||||
font-size: .875rem;
|
font-size: .875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-separator-vertical
|
||||||
|
{
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-separator-horizontal
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-mobile-menu .top-nav-separator-vertical
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-mobile-menu .top-nav-separator-horizontal
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-more-menu
|
||||||
|
{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: fit-content(35%) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-desktop-submenu .top-nav-menu-link
|
||||||
|
{
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.top-nav-desktop-menu .top-nav-menu-link
|
.top-nav-desktop-menu .top-nav-menu-link
|
||||||
{
|
{
|
||||||
padding-inline: 8px;
|
padding-inline: 8px;
|
||||||
@@ -242,8 +321,42 @@ body
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.top-nav-account .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 24vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-locale-name
|
||||||
|
{
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px)
|
@media (min-width: 768px)
|
||||||
{
|
{
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-primary,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-account
|
||||||
|
{
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: max-content;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-locale-name,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-locale-name
|
||||||
|
{
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-account .top-nav-user-desktop,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-account .top-nav-user-desktop
|
||||||
|
{
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.top-nav-layout .top-nav-brand-link
|
.top-nav-layout .top-nav-brand-link
|
||||||
{
|
{
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
@@ -255,7 +368,9 @@ body
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
|
.top-nav-layout[data-desktop='true'] .top-nav-desktop-menu,
|
||||||
.top-nav-layout[data-measuring] .top-nav-desktop-menu
|
.top-nav-layout[data-measuring] .top-nav-desktop-menu,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-account,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-account
|
||||||
{
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
@@ -269,6 +384,8 @@ body
|
|||||||
|
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-toggle,
|
||||||
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
|
.top-nav-layout[data-measuring] .top-nav-mobile-toggle,
|
||||||
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-controls,
|
||||||
|
.top-nav-layout[data-measuring] .top-nav-mobile-controls,
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
|
.top-nav-layout[data-desktop='true'] .top-nav-mobile-menu,
|
||||||
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
|
.top-nav-layout[data-desktop='true'] .top-nav-user-mobile
|
||||||
{
|
{
|
||||||
|
|||||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする