コミットを比較

..
7 コミット
作成者 SHA1 メッセージ 日付
みてるぞ 0c1f459d4e #428 2026-10-18 01:09:31 +09:00
みてるぞ 9be0c7b1d9 #428 2026-10-18 00:20:30 +09:00
みてるぞ 63860c94af #428 2026-10-18 00:07:07 +09:00
みてるぞ 851d5e8e09 #428 2026-10-17 23:49:20 +09:00
みてるぞ 91296f7477 #428 2026-10-17 23:01:24 +09:00
みてるぞ f063fc54bb #428 2026-10-17 19:59:18 +09:00
みてるぞ b945ceef60 #428 2026-10-17 16:47:08 +09:00
142個のファイルの変更、5985行の追加、1090行の削除
+23 -1
ファイルの表示
@@ -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
+10
ファイルの表示
@@ -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
+26 -12
ファイルの表示
@@ -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,
@@ -106,17 +111,23 @@ 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)) }]
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']] }]
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 +136,20 @@ 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.to_h { [_1[:identity], _1[:name]] }
previous = prev_tags.to_h { [_1[:identity], _1[:name]] }
(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: } { name: current[identity] || previous[identity], type: }
end end
end end
end end
+1 -1
ファイルの表示
@@ -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
+6 -14
ファイルの表示
@@ -1,26 +1,18 @@
class Locale < ApplicationRecord class Locale < ApplicationRecord
after_create_commit :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 needs_tag_names?
ApplicationRecord.transaction do deprecated_at.nil? && (saved_change_to_code? || saved_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 generate_tag_names! = TagNameCompletion.for_locale!(self)
end end
+1 -1
ファイルの表示
@@ -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
+9 -28
ファイルの表示
@@ -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
@@ -263,16 +263,19 @@ class Tag < ApplicationRecord
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 +355,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
+10 -2
ファイルの表示
@@ -34,9 +34,15 @@ 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 }"
return fallback unless locale.language_code == 'ain' && base_tag_name&.language_code == 'ja'
"Tag_##{ tag.id }" name = TagNameTranscription.ainu(base_tag_name.name)
candidate = new(tag:, name:, language_code: 'ain', script_code: 'Kana',
primary_flg: true, auto_generated: true)
candidate.valid? ? name : fallback
rescue TagNameTranscription::Unconvertible
fallback
end end
private private
@@ -54,6 +60,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
+47
ファイルの表示
@@ -0,0 +1,47 @@
class TagNameCompletion
class << self
def for_locale! locale
named_tags =
TagName.where(language_code: locale.language_code, primary_flg: true)
.select(:tag_id)
Tag.where.not(id: named_tags).find_each do |tag|
for_tag!(tag, locales: [locale])
end
end
def for_tag! tag, locales: nil
locales ||= Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code)
tag.with_lock do
primary_names = tag.tag_names.where(primary_flg: true).index_by(&:language_code)
locales.to_a.uniq(&:language_code).each do |locale|
next if primary_names.key?(locale.language_code)
name = TagName.generate_name(locale, tag, primary_names['ja'])
primary_names[locale.language_code] = create_name!(locale, tag, name)
end
tag.association(:tag_names).reset
end
end
private
def create_name! locale, tag, name
persist_name!(locale, tag, name)
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => error
fallback = "Tag_##{ tag.id }"
collision =
error.is_a?(ActiveRecord::RecordNotUnique) ||
error.record.errors.of_kind?(:name, :taken)
raise if name == fallback || !(collision)
persist_name!(locale, tag, fallback)
end
def persist_name! locale, 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: true)
end
end
end
end
+56
ファイルの表示
@@ -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
+13
ファイルの表示
@@ -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
+26
ファイルの表示
@@ -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
+30
ファイルの表示
@@ -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
+59
ファイルの表示
@@ -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
+2
ファイルの表示
@@ -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]
ファイル差分が大きすぎるため省略します 差分を読み込み
+5
ファイルの表示
@@ -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',
+1 -11
ファイルの表示
@@ -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
+2 -3
ファイルの表示
@@ -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
+82
ファイルの表示
@@ -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
+42 -50
ファイルの表示
@@ -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
+3 -4
ファイルの表示
@@ -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
+12 -14
ファイルの表示
@@ -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
+25 -29
ファイルの表示
@@ -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
+63 -74
ファイルの表示
@@ -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(
+54
ファイルの表示
@@ -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
+61 -78
ファイルの表示
@@ -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
+16 -15
ファイルの表示
@@ -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 -20
ファイルの表示
@@ -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
+2 -3
ファイルの表示
@@ -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)
+19 -21
ファイルの表示
@@ -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
+3 -4
ファイルの表示
@@ -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
+68
ファイルの表示
@@ -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
+34
ファイルの表示
@@ -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
+14 -17
ファイルの表示
@@ -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)
+7 -4
ファイルの表示
@@ -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
+10 -15
ファイルの表示
@@ -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
生成ファイル
+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",
+2
ファイルの表示
@@ -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",
+106 -37
ファイルの表示
@@ -1,11 +1,14 @@
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, 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'
@@ -13,6 +16,10 @@ import TopNav from '@/components/TopNav'
import DialogueProvider from '@/components/dialogues/DialogueProvider' import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { 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,
@@ -36,7 +43,7 @@ import MorePage from '@/pages/MorePage'
import GekanatorPage from '@/pages/GekanatorPage' import GekanatorPage from '@/pages/GekanatorPage'
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,6 +69,7 @@ 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'
@@ -73,41 +81,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={<GekanatorPage user={user}/>}/>
<Route path="/more" element={<MorePage/>}/> <Route path="more" element={<MorePage/>}/>
<Route path="*" element={<NotFound/>}/> <Route path="*" element={<NotFound/>}/>
</Routes>) </Routes>)
@@ -128,7 +136,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 ()
@@ -243,7 +251,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 +267,70 @@ 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 pageLocale = requiredPageLocale (location.pathname)
const activeLocale =
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale)
&& (pageLocale == null || pageLocale === 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 (pageLocale != null && isLocaleCode (locale) && pageLocale !== locale)
{
return data.locales.some (entry => entry.code === pageLocale)
? <Navigate to={switchLocalePath (location, pageLocale)} replace/>
: <NotFound/>
}
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 = () => (
+6 -1
ファイルの表示
@@ -8,6 +8,8 @@ const COLUMN_COUNT = 12
const DevModeWatermark: FC = () => { const DevModeWatermark: FC = () => {
const { t } = useTranslation ('shared')
const text = t ('labels.development', { returnDetails: true })
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
@@ -29,13 +31,14 @@ const DevModeWatermark: FC = () => {
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => ( {Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
<span <span
key={columnIndex} key={columnIndex}
lang={text.usedLng}
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.res}
</span>))} </span>))}
</div>))} </div>))}
</div> </div>
@@ -44,3 +47,5 @@ const DevModeWatermark: FC = () => {
export default DevModeWatermark export default DevModeWatermark
import { useTranslation } from 'react-i18next'
+10 -10
ファイルの表示
@@ -4,6 +4,7 @@ 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 { SITE_TITLE } from '@/config'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC } from 'react' import type { FC } from 'react'
@@ -11,17 +12,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
} }
@@ -51,13 +51,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
+53
ファイルの表示
@@ -0,0 +1,53 @@
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
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')
{
return formatUIString (child, language).split (/(\u3000+)/).map ((part, index) =>
part.includes ('\u3000')
? <Fragment key={index}>{part}<wbr/></Fragment>
: <span key={index} className="ainu-ui-word">{part}</span>)
}
if (isValidElement<{ children?: ReactNode; lang?: string }> (child))
{
if (child.type === 'code' || child.type === 'pre'
|| (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
+13 -9
ファイルの表示
@@ -1,7 +1,8 @@
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'
@@ -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)
@@ -125,10 +126,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 +202,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 +213,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 +385,9 @@ const MobileMaterialTreeNode: FC<{ depth?: number
const MaterialSidebar: FC = () => { const MaterialSidebar: FC = () => {
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 +399,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)
+3 -3
ファイルの表示
@@ -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
+3 -1
ファイルの表示
@@ -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'
@@ -51,6 +52,7 @@ type Props = { post: Post
const PostEditForm: FC<Props> = ({ post, onSave }) => { const PostEditForm: FC<Props> = ({ post, onSave }) => {
const locale = useLocale ()
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 +77,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,
+6 -2
ファイルの表示
@@ -2,6 +2,8 @@
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'
@@ -38,6 +40,8 @@ type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | '
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 -1
ファイルの表示
@@ -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
+9 -6
ファイルの表示
@@ -1,6 +1,7 @@
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 } from 'react'
@@ -19,9 +20,11 @@ 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">
@@ -46,7 +49,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
d.setMinutes (d.getMinutes () + 1) d.setMinutes (d.getMinutes () + 1)
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ())) setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
}}/> }}/>
以降 {label ('labels.since')}
</div> </div>
<div> <div>
<Button <Button
@@ -56,7 +59,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
onClick={() => { onClick={() => {
setOriginalCreatedFrom (null) setOriginalCreatedFrom (null)
}}> }}>
リセット {label ('actions.reset')}
</Button> </Button>
</div> </div>
</div> </div>
@@ -71,7 +74,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
invalid={invalid} invalid={invalid}
value={originalCreatedBefore ?? undefined} value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/> onChange={setOriginalCreatedBefore}/>
より前 {label ('labels.before')}
</div> </div>
<div> <div>
<Button <Button
@@ -81,7 +84,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
onClick={() => { onClick={() => {
setOriginalCreatedBefore (null) setOriginalCreatedBefore (null)
}}> }}>
リセット {label ('actions.reset')}
</Button> </Button>
</div> </div>
</div> </div>
+11 -5
ファイルの表示
@@ -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)
{ {
+3 -1
ファイルの表示
@@ -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 (':')
+12 -7
ファイルの表示
@@ -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'
@@ -27,6 +28,7 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, originalCreatedAtString } from '@/lib/utils'
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 +182,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 +231,7 @@ type ActiveTagDrag = {
const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => { const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
const locale = useLocale ()
sp = Boolean (sp) sp = Boolean (sp)
const qc = useQueryClient () const qc = useQueryClient ()
@@ -271,7 +276,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,9 +319,9 @@ 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 ({
@@ -333,10 +338,10 @@ 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)
+11 -3
ファイルの表示
@@ -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>)}
+8 -4
ファイルの表示
@@ -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)}
+11 -7
ファイルの表示
@@ -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>)
} }
+222 -113
ファイルの表示
@@ -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 { 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,6 +141,7 @@ 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 measure = (idx: number) => { const measure = (idx: number) => {
const nav = navRef.current const nav = navRef.current
@@ -145,31 +170,33 @@ const TopNav: FC<Props> = ({ user }) => {
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)
@@ -177,26 +204,25 @@ const TopNav: FC<Props> = ({ user }) => {
!(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)
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 +241,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 +263,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 +300,8 @@ 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) if (account != null)
observer?.observe (userLink) observer?.observe (account)
update () update ()
addEventListener ('resize', schedule) addEventListener ('resize', schedule)
document.fonts?.addEventListener ('loadingdone', schedule) document.fonts?.addEventListener ('loadingdone', schedule)
@@ -283,22 +314,68 @@ 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])
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 +384,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}
@@ -339,7 +416,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 +434,28 @@ 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')}>
その他 &raquo; <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"
}}> onClick={() => {
{menuOpen ? '×' : 'Menu'} setMenuOpen (!(menuOpen))
</button> }}>
{menuOpen ? '×' : 'Menu'}
</button>
</div>
</nav> </nav>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
@@ -389,12 +474,11 @@ const TopNav: FC<Props> = ({ user }) => {
onAnimationComplete={() => { onAnimationComplete={() => {
measure (moreVsbl ? -1 : activeIdx) measure (moreVsbl ? -1 : activeIdx)
}}> }}>
<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
@@ -404,9 +488,14 @@ const TopNav: FC<Props> = ({ user }) => {
: { 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"> className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>{item.name}</h2> <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,6 +503,7 @@ 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
? { } ? { }
@@ -428,6 +518,7 @@ 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
? { } ? { }
@@ -441,49 +532,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,6 +589,7 @@ 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={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
{item.component} {item.component}
@@ -502,6 +597,7 @@ const TopNav: FC<Props> = ({ user }) => {
: ( : (
<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={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
<PrefetchLink <PrefetchLink
@@ -509,13 +605,17 @@ const TopNav: FC<Props> = ({ user }) => {
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 +640,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 +663,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 +674,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'))}>
その他 &raquo; <TranslatedText messageKey="more"/>
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
<Separator/> <Separator/>
@@ -605,7 +709,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 +745,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 +758,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'))}>
その他 &raquo; <TranslatedText messageKey="more"/>
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
<Separator/> <Separator/>
+4 -3
ファイルの表示
@@ -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>
</>) </>)
} }
+23
ファイルの表示
@@ -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
+9 -4
ファイルの表示
@@ -3,6 +3,8 @@ 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'
@@ -26,10 +28,12 @@ const mdComponents = { a: (({ href, children }) => (
const WikiBody: FC<Props> = ({ title, body }) => { const WikiBody: FC<Props> = ({ title, body }) => {
const { message } = useUITranslation ('wiki')
const locale = useLocale ()
const { data } = useQuery ({ const { data } = useQuery ({
enabled: Boolean (body), enabled: Boolean (body),
queryKey: wikiKeys.index ({ }), queryKey: wikiKeys.index ({ }, locale),
queryFn: () => fetchWikiPages ({ }) }) queryFn: () => fetchWikiPages ({ }, locale) })
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length) const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
const remarkPlugins = useMemo ( const remarkPlugins = useMemo (
@@ -37,8 +41,9 @@ const WikiBody: FC<Props> = ({ title, body }) => {
return ( return (
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} {body || message ('missingPage', {
url: `/wiki/new?title=${ encodeURIComponent (title) }` }).text}
</ReactMarkdown>) </ReactMarkdown>)
} }
export default WikiBody export default WikiBody
+13 -5
ファイルの表示
@@ -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}>
|&lt; |&lt;
</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}>
&lt; &lt;
</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}>
&gt; &gt;
</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}>
&gt;| &gt;|
</PrefetchLink> </PrefetchLink>
</>) </>)
+3 -1
ファイルの表示
@@ -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)
+3 -2
ファイルの表示
@@ -88,8 +88,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]
}, []) }, [])
+3 -2
ファイルの表示
@@ -1,6 +1,7 @@
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'
@@ -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}
+3 -3
ファイルの表示
@@ -1,4 +1,5 @@
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 } from 'react'
@@ -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 -1
ファイルの表示
@@ -5,7 +5,7 @@ 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?: string[]
+4 -2
ファイルの表示
@@ -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)}/>)
+2 -9
ファイルの表示
@@ -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
+1 -1
ファイルの表示
@@ -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
+93 -1
ファイルの表示
@@ -4,6 +4,20 @@
@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
{
white-space: nowrap !important;
word-break: keep-all !important;
overflow-wrap: normal !important;
}
@layer base @layer base
{ {
:root :root
@@ -208,6 +222,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 +236,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 +247,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 +296,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 +343,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 +359,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
{ {
+11 -4
ファイルの表示
@@ -5,7 +5,10 @@ import { API_BASE_URL } from '@/config'
import type { AxiosError, AxiosRequestConfig } from 'axios' import type { AxiosError, AxiosRequestConfig } from 'axios'
import type { LocaleCode } from '@/lib/locales'
type Opt = { type Opt = {
locale?: LocaleCode
params?: AxiosRequestConfig['params'] params?: AxiosRequestConfig['params']
headers?: Record<string, string> headers?: Record<string, string>
signal?: AbortSignal signal?: AbortSignal
@@ -14,10 +17,14 @@ type Opt = {
const client = axios.create ({ baseURL: API_BASE_URL }) const client = axios.create ({ baseURL: API_BASE_URL })
const withUserCode = (opt?: Opt): Opt => ({ const withUserCode = (opt?: Opt): AxiosRequestConfig => {
...opt, const { locale, ...config } = opt ?? { }
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '', return {
...(opt?.headers ?? { }) } }) ...config,
...(locale != null && { params: { locale, ...config.params } }),
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
...(config.headers ?? { }) } }
}
const apiP = async <T> ( const apiP = async <T> (
+31 -15
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, apiPost } from '@/lib/api' import { apiGet, apiPost } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Post } from '@/types' import type { Post } from '@/types'
export type GekanatorAnswerValue = export type GekanatorAnswerValue =
@@ -411,24 +412,30 @@ export const storeGekanatorQuestion = (
exampleAnswers: question.exampleAnswers }) exampleAnswers: question.exampleAnswers })
export const fetchGekanatorPosts = async (): Promise<Post[]> => { export const fetchGekanatorPosts = async (
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts') locale: LocaleCode = 'ja',
): Promise<Post[]> => {
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts', { locale })
return data.posts return data.posts
} }
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => { export const fetchGekanatorQuestions = async (
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions') locale: LocaleCode = 'ja',
): Promise<StoredGekanatorQuestion[]> => {
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions', { locale })
return data.questions return data.questions
} }
export const fetchGekanatorExtraQuestions = async ( export const fetchGekanatorExtraQuestions = async (
gameId: number, gameId: number,
nonce?: string): Promise<GekanatorExtraQuestion[]> => { nonce?: string,
locale: LocaleCode = 'ja',
): Promise<GekanatorExtraQuestion[]> => {
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> ( const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
`/gekanator/games/${ gameId }/extra_questions`, `/gekanator/games/${ gameId }/extra_questions`,
{ params: nonce ? { nonce } : undefined }) { locale, params: nonce ? { nonce } : undefined })
return data.questions return data.questions
} }
@@ -606,7 +613,8 @@ export const buildGekanatorQuestions = (
} }
export const saveGekanatorGame = async ({ export const saveGekanatorGame = async (
{
guessedPostId, guessedPostId,
correctPostId, correctPostId,
answers, answers,
@@ -614,7 +622,9 @@ export const saveGekanatorGame = async ({
guessedPostId: number guessedPostId: number
correctPostId: number correctPostId: number
answers: GekanatorAnswerLog[] answers: GekanatorAnswerLog[]
}): Promise<{ id: number; learnedExampleCount: number }> => },
locale: LocaleCode = 'ja',
): Promise<{ id: number; learnedExampleCount: number }> =>
await apiPost ('/gekanator/games', { await apiPost ('/gekanator/games', {
guessed_post_id: guessedPostId, guessed_post_id: guessedPostId,
correct_post_id: correctPostId, correct_post_id: correctPostId,
@@ -627,10 +637,11 @@ export const saveGekanatorGame = async ({
effective_question: answer.effectiveQuestion, effective_question: answer.effectiveQuestion,
learning_question: answer.learningQuestion, learning_question: answer.learningQuestion,
answer: answer.answer, answer: answer.answer,
original_answer: answer.originalAnswer })) }) original_answer: answer.originalAnswer })) }, { locale })
export const saveGekanatorQuestionSuggestion = async ({ export const saveGekanatorQuestionSuggestion = async (
{
gekanatorGameId, gekanatorGameId,
existingQuestionId, existingQuestionId,
questionText, questionText,
@@ -640,22 +651,27 @@ export const saveGekanatorQuestionSuggestion = async ({
existingQuestionId?: number existingQuestionId?: number
questionText?: string questionText?: string
answer: GekanatorAnswerValue answer: GekanatorAnswerValue
}): Promise<{ id: number; count: number }> => },
locale: LocaleCode = 'ja',
): Promise<{ id: number; count: number }> =>
await apiPost ('/gekanator/question_suggestions', { await apiPost ('/gekanator/question_suggestions', {
gekanator_game_id: gekanatorGameId, gekanator_game_id: gekanatorGameId,
existing_question_id: existingQuestionId, existing_question_id: existingQuestionId,
question_text: questionText, question_text: questionText,
answer }) answer }, { locale })
export const saveGekanatorExtraQuestionAnswers = async ({ export const saveGekanatorExtraQuestionAnswers = async (
{
gameId, gameId,
answers, answers,
}: { }: {
gameId: number gameId: number
answers: { questionId: number; answer: GekanatorAnswerValue }[] answers: { questionId: number; answer: GekanatorAnswerValue }[]
}) => },
locale: LocaleCode = 'ja',
) =>
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, { await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
answers: answers.map (item => ({ answers: answers.map (item => ({
question_id: item.questionId, question_id: item.questionId,
answer: item.answer })) }) answer: item.answer })) }, { locale })
+13 -2
ファイルの表示
@@ -13,12 +13,14 @@ export type HelpEditionText = {
export type HelpSearchEntry = HelpEditionText & { export type HelpSearchEntry = HelpEditionText & {
identifier: string identifier: string
language?: string
edition: string } edition: string }
export type HelpEditionContent = HelpEditionText & { content: ComponentType } export type HelpEditionContent = HelpEditionText & { content: ComponentType }
export type HelpEditionSource = HelpEditionText & { source: string } export type HelpEditionSource = HelpEditionText & { source: string }
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & { export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
language?: string
pred: string | null pred: string | null
change: 'new' | 'update' change: 'new' | 'update'
date: string } date: string }
@@ -82,6 +84,15 @@ export const getHelpArticle = async (identifier: string): Promise<ArticleMeta |
} }
export const resolveHelpLanguage = (
article: ArticleMeta | null | undefined,
requestedLanguage: string,
): string | null =>
article?.languages[requestedLanguage] != null
? requestedLanguage
: null
export const getHelpEditions = async ( export const getHelpEditions = async (
identifier: string, identifier: string,
language: string, language: string,
@@ -144,7 +155,7 @@ export const getHelpSearchEntries = async (
language: string, language: string,
): Promise<HelpSearchEntry[]> => { ): Promise<HelpSearchEntry[]> => {
const { default: search } = await import ('virtual:help/search') const { default: search } = await import ('virtual:help/search')
return search[language] ?? [] return (search[language] ?? []).map (entry => ({ ...entry, language }))
} }
@@ -163,7 +174,7 @@ export const getHelpEditionContent = async (
export const getHelpChanges = async (language: string): Promise<HelpChange[]> => { export const getHelpChanges = async (language: string): Promise<HelpChange[]> => {
const { default: changes } = await import ('virtual:help/changes') const { default: changes } = await import ('virtual:help/changes')
return changes[language] ?? [] return (changes[language] ?? []).map (entry => ({ ...entry, language }))
} }
+89
ファイルの表示
@@ -0,0 +1,89 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import type { LocaleCode } from '@/lib/locales'
export const i18n = createInstance ()
// Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
const untranslated = new Set (
Array.isArray (resource._untranslated) ? resource._untranslated : [])
if (untranslated.has ('*'))
return { }
const select = (entries: Record<string, unknown>, prefix = ''): Record<string, unknown> =>
Object.fromEntries (Object.entries (entries).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
if (key === '_untranslated' || untranslated.has (path))
return []
const translated =
(value != null && typeof value === 'object' && !(Array.isArray (value)))
? select (value as Record<string, unknown>, path)
: value
return [[key, translated]]
}))
return select (resource)
}
const dictionaries = import.meta.glob<Record<string, unknown>> ('../locales/*/*.json', {
eager: true, import: 'default' })
const resources: Record<string, Record<string, Record<string, unknown>>> = { }
for (const [path, dictionary] of Object.entries (dictionaries))
{
const [, , locale, filename] = path.split ('/')
resources[locale] ??= { }
resources[locale][filename.slice (0, -5)] =
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
}
const namespaces = Object.keys (resources.ja ?? { })
void i18n.use (initReactI18next).init ({
lng: 'ja',
fallbackLng: 'ja',
supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly',
initAsync: false,
defaultNS: 'navigation',
ns: namespaces,
resources,
interpolation: { escapeValue: false },
returnNull: false,
returnEmptyString: false,
saveMissing: import.meta.env.DEV,
missingKeyHandler: (languages, namespace, key) => {
console.warn ('[i18n] missing key', { languages, namespace, key })
},
react: { useSuspense: false } })
export const untranslatedKeys = (
locale: LocaleCode,
namespace = 'navigation',
): string[] => {
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
Object.entries (resource).flatMap (([key, value]) => {
const path = prefix ? `${ prefix }.${ key }` : key
return (value != null && typeof value === 'object')
? collect (value as Record<string, unknown>, path)
: [path]
})
const baseline = i18n.getResourceBundle ('ja', namespace) ?? { }
return collect (baseline).filter (key => {
const value = i18n.getResource (locale, namespace, key)
return typeof value !== 'string' || value.length === 0
})
}
if (import.meta.env.DEV)
{
for (const namespace of namespaces)
{
const missing = untranslatedKeys ('ain-Kana', namespace)
if (missing.length > 0)
console.warn (`[i18n] ain-Kana: untranslated ${ namespace } keys`, missing)
}
}
+4 -1
ファイルの表示
@@ -1,3 +1,5 @@
import { unlocalisedPath } from '@/lib/locales'
export type ShortcutScope = export type ShortcutScope =
| 'global' | 'global'
| 'posts' | 'posts'
@@ -308,7 +310,8 @@ export const getConflictingShortcutActionIds = (
) )
} }
export const resolveShortcutScope = (pathname: string): ShortcutScope => { export const resolveShortcutScope = (fullPathname: string): ShortcutScope => {
const pathname = unlocalisedPath (fullPathname)
if (pathname.startsWith ('/posts/search')) if (pathname.startsWith ('/posts/search'))
return 'postSearch' return 'postSearch'
if (pathname.startsWith ('/posts')) if (pathname.startsWith ('/posts'))
+55
ファイルの表示
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { isLocaleCode,
legacyLocalePath,
localeFromPath,
localiseTo,
switchLocalePath,
unlocalisedPath } from '@/lib/locales'
describe ('locale URL generation', () => {
it.each (['ja', 'ain-Kana']) ('recognises the supported locale %s', locale => {
expect (isLocaleCode (locale)).toBe (true)
expect (localeFromPath (`/${ locale }/posts`)).toBe (locale)
})
it.each (['en', 'ain', 'JA', '', undefined]) ('rejects %s', locale => {
expect (isLocaleCode (locale)).toBe (false)
})
it.each ([
'/posts', '/posts/new', '/posts/search', '/posts/42', '/posts/changes',
'/tags', '/tags/42', '/tags/42/deerjikists', '/tags/nico', '/nico/tags', '/tags/changes',
'/materials', '/materials/new', '/materials/42', '/materials/changes',
'/materials/suppressions', '/theatres/42', '/wiki', '/wiki/new', '/wiki/title',
'/wiki/42/edit', '/wiki/42/diff', '/wiki/changes', '/help', '/help/changes',
'/help/article', '/help/article/diff', '/users/settings', '/settings',
'/tos', '/gekanator', '/more']) ('localises and redirects %s', pathname => {
const location = { pathname, search: '?page=2&tag=a%23b', hash: '#section' }
expect (localiseTo (pathname, 'ain-Kana')).toBe (`/ain-Kana${ pathname }`)
expect (legacyLocalePath (location)).toBe (`/ja${ pathname }${ location.search }#section`)
expect (unlocalisedPath (`/ain-Kana${ pathname }`)).toBe (pathname)
})
it ('preserves the resource, query and fragment on locale changes', () => {
expect (switchLocalePath ({ pathname: '/ja/tags/42', search: '?x=1&x=2', hash: '#history' },
'ain-Kana')).toBe ('/ain-Kana/tags/42?x=1&x=2#history')
})
it ('preserves explicit locales and relative, fragment and external targets', () => {
for (const target of ['/ja/posts', '/ain-Kana/posts', '?page=2', '#body',
'../changes', 'https://example.com/posts', '//example.com/posts'])
expect (localiseTo (target, 'ain-Kana')).toBe (target)
})
it ('preserves structured targets', () => {
expect (localiseTo ({ pathname: '/tags/42', search: '?q=one', hash: '#two' }, 'ain-Kana'))
.toEqual ({ pathname: '/ain-Kana/tags/42', search: '?q=one', hash: '#two' })
})
it.each (['/en/posts', '/ain/posts', '/unknown', '/api/tags', '/assets/image.png']) (
'does not redirect invalid or non-page paths: %s', pathname => {
expect (legacyLocalePath ({ pathname, search: '', hash: '' })).toBeNull ()
})
})
+71
ファイルの表示
@@ -0,0 +1,71 @@
import { apiGet } from '@/lib/api'
import type { To } from 'react-router-dom'
export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
export type AvailableLocale = { code: string; name: string }
export const fetchLocales = (): Promise<{ locales: AvailableLocale[] }> =>
apiGet ('/locales')
export const LOCALE_LABELS: Record<LocaleCode, string> = {
ja: '日本語', 'ain-Kana': 'ヤウンモシルン アイヌ イタㇰ' }
const LEGACY_ROOTS = [
'posts', 'tags', 'nico', 'theatres', 'materials', 'wiki', 'help',
'users', 'settings', 'tos', 'gekanator', 'more']
export const isLocaleCode = (value: unknown): value is LocaleCode =>
SUPPORTED_LOCALES.some (locale => locale === value)
export const localeLanguage = (locale: LocaleCode): 'ja' | 'ain' =>
locale === 'ja' ? 'ja' : 'ain'
export const localeFromPath = (pathname: string): LocaleCode | null => {
const segment = pathname.split (/[/?#]/)[1]
return isLocaleCode (segment) ? segment : null
}
export const unlocalisedPath = (pathname: string): string => {
const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
}
export const localiseTo = (to: To, locale: LocaleCode): To => {
const pathname = typeof to === 'string' ? to : to.pathname
if (pathname == null
|| !(pathname.startsWith ('/'))
|| pathname.startsWith ('//')
|| localeFromPath (pathname) != null)
return to
const localised = `/${ locale }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised }
}
export const switchLocalePath = (
location: { pathname: string; search: string; hash: string },
locale: LocaleCode,
): string =>
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
): string | null => {
const root = location.pathname.split ('/')[1]
return (location.pathname === '/' || LEGACY_ROOTS.includes (root))
? `/ja${ location.pathname }${ location.search }${ location.hash }`
: null
}
+38 -17
ファイルの表示
@@ -1,5 +1,6 @@
import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api' import { apiGet, isApiError, apiPost, apiPut } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Material, import type { Material,
MaterialIndexResponse, MaterialIndexResponse,
MaterialVersion, MaterialVersion,
@@ -36,8 +37,10 @@ export const fetchMaterials = async (
updatedFrom, updatedTo, sort, direction, page, updatedFrom, updatedTo, sort, direction, page,
tagId, includeDescendants, groupBy, tagId, includeDescendants, groupBy,
limit }: FetchMaterialsParams, limit }: FetchMaterialsParams,
locale: LocaleCode = 'ja',
): Promise<MaterialIndexResponse> => ): Promise<MaterialIndexResponse> =>
await apiGet ('/materials', { params: { await apiGet ('/materials', { locale, params: {
...(q && { q }), ...(q && { q }),
tag_state: tagState, tag_state: tagState,
media_kind: mediaKind, media_kind: mediaKind,
@@ -54,10 +57,13 @@ export const fetchMaterials = async (
limit} }) limit} })
export const fetchMaterial = async (id: string): Promise<Material | null> => { export const fetchMaterial = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Material | null> => {
try try
{ {
return await apiGet (`/materials/${ id }`) return await apiGet (`/materials/${ id }`, { locale })
} }
catch (error) catch (error)
{ {
@@ -75,8 +81,10 @@ export const fetchMaterialChanges = async (
eventType?: string eventType?: string
page: number page: number
limit: number limit: number
}): Promise<MaterialChangesResponse> => },
await apiGet ('/materials/versions', { params: { locale: LocaleCode = 'ja',
): Promise<MaterialChangesResponse> =>
await apiGet ('/materials/versions', { locale, params: {
...(materialId && { material_id: materialId }), ...(materialId && { material_id: materialId }),
...(tag && { tag }), ...(tag && { tag }),
...(eventType && { event_type: eventType }), ...(eventType && { event_type: eventType }),
@@ -85,19 +93,23 @@ export const fetchMaterialChanges = async (
export const fetchMaterialTagTree = async ( export const fetchMaterialTagTree = async (
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> => { parentId, materialFilter }: FetchMaterialTreeParams,
await apiGet ('/tags/with-depth', { params: { locale: LocaleCode = 'ja',
): Promise<MaterialSidebarTag[]> =>
await apiGet ('/tags/with-depth', { locale, params: {
...(parentId != null && { parent: String (parentId) }), ...(parentId != null && { parent: String (parentId) }),
material_filter: materialFilter} }) material_filter: materialFilter} })
export const fetchMaterialTagByName = async ( export const fetchMaterialTagByName = async (
name: string, name: string,
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => { materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
): Promise<MaterialTagTree | null> => {
try try
{ {
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`, return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
{ params: { material_filter: materialFilter } }) { locale, params: { material_filter: materialFilter } })
} }
catch (error) catch (error)
{ {
@@ -108,19 +120,26 @@ export const fetchMaterialTagByName = async (
} }
export const createMaterial = async (formData: FormData): Promise<Material> => export const createMaterial = async (
await apiPost ('/materials', formData) formData: FormData,
locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPost ('/materials', formData, { locale })
export const updateMaterial = async ( export const updateMaterial = async (
id: string, id: string,
formData: FormData): Promise<Material> => formData: FormData,
await apiPut (`/materials/${ id }`, formData) locale: LocaleCode = 'ja',
): Promise<Material> =>
await apiPut (`/materials/${ id }`, formData, { locale })
export const fetchMaterialSyncSuppressions = export const fetchMaterialSyncSuppressions =
async (): Promise<MaterialSyncSuppressionResponse> => async (
await apiGet ('/materials/suppressions') locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppressionResponse> =>
await apiGet ('/materials/suppressions', { locale })
export const createMaterialSyncSuppression = async ( export const createMaterialSyncSuppression = async (
@@ -130,10 +149,12 @@ export const createMaterialSyncSuppression = async (
drivePath?: string drivePath?: string
driveFileId?: string driveFileId?: string
reason: string reason: string
}): Promise<MaterialSyncSuppression> => },
locale: LocaleCode = 'ja',
): Promise<MaterialSyncSuppression> =>
await apiPost ('/materials/suppressions', { await apiPost ('/materials/suppressions', {
source_kind: payload.sourceKind, source_kind: payload.sourceKind,
source_uri: payload.sourceUri, source_uri: payload.sourceUri,
drive_path: payload.drivePath, drive_path: payload.drivePath,
drive_file_id: payload.driveFileId, drive_file_id: payload.driveFileId,
reason: payload.reason}) reason: payload.reason}, { locale })
+19
ファイルの表示
@@ -0,0 +1,19 @@
import { i18n } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json'
import type { LocaleCode } from '@/lib/locales'
export type UIMessageKey = keyof typeof navigation
export const uiMessage = (
locale: LocaleCode,
key: UIMessageKey,
values: Record<string, string | number> = { },
): string => i18n.t (key, { ...values, lng: locale, ns: 'navigation' })
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
i18n.t (key, { lng: locale, ns: 'navigation', returnDetails: true }).usedLng === 'ain-Kana'
? 'ain'
: 'ja'
+17 -7
ファイルの表示
@@ -1,14 +1,17 @@
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api' import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { FetchPostsParams, Post, PostVersion } from '@/types' import type { FetchPostsParams, Post, PostVersion } from '@/types'
export const fetchPosts = async ( export const fetchPosts = async (
{ url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo, { url, title, tags, match, createdFrom, createdTo, updatedFrom, updatedTo,
originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams): Promise<{ originalCreatedFrom, originalCreatedTo, page, limit, order }: FetchPostsParams,
locale: LocaleCode = 'ja',
): Promise<{
posts: Post[] posts: Post[]
count: number }> => count: number }> =>
await apiGet ('/posts', { params: { await apiGet ('/posts', { locale, params: {
...(url && { url }), ...(url && { url }),
...(title && { title }), ...(title && { title }),
...(tags && { tags }), ...(tags && { tags }),
@@ -24,7 +27,10 @@ export const fetchPosts = async (
...(order && { order }) } }) ...(order && { order }) } })
export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) export const fetchPost = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Post> => await apiGet (`/posts/${ id }`, { locale })
export const fetchPostChanges = async ( export const fetchPostChanges = async (
@@ -33,10 +39,12 @@ export const fetchPostChanges = async (
tag?: string tag?: string
externalTag?: string externalTag?: string
page: number page: number
limit: number }): Promise<{ limit: number },
locale: LocaleCode = 'ja',
): Promise<{
versions: PostVersion[] versions: PostVersion[]
count: number }> => count: number }> =>
await apiGet ('/posts/versions', { params: { ...(post && { post }), await apiGet ('/posts/versions', { locale, params: { ...(post && { post }),
...(tag && { tag }), ...(tag && { tag }),
...(externalTag && { ...(externalTag && {
external_tag: externalTag }), external_tag: externalTag }),
@@ -54,7 +62,9 @@ export const updatePost = async (
{ baseVersionNo, force, merge }: { { baseVersionNo, force, merge }: {
baseVersionNo?: number baseVersionNo?: number
force?: boolean force?: boolean
merge?: boolean }) => merge?: boolean },
locale: LocaleCode = 'ja',
) =>
await apiPut<Post> ( await apiPut<Post> (
`/posts/${ post.id }`, `/posts/${ post.id }`,
{ title: post.title, { title: post.title,
@@ -63,7 +73,7 @@ export const updatePost = async (
parent_post_ids: post.parentPostIds, parent_post_ids: post.parentPostIds,
original_created_from: post.originalCreatedFrom, original_created_from: post.originalCreatedFrom,
original_created_before: post.originalCreatedBefore }, original_created_before: post.originalCreatedBefore },
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }), { locale, params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
force: force ? '1' : '0', force: force ? '1' : '0',
merge: merge ? '1' : '0' } }) merge: merge ? '1' : '0' } })
+68 -51
ファイルの表示
@@ -5,7 +5,9 @@ import { getHelpArticle,
getHelpChanges, getHelpChanges,
getHelpDiff, getHelpDiff,
getHelpEditionContent, getHelpEditionContent,
getHelpSearchEntries } from '@/lib/help' getHelpSearchEntries,
resolveHelpLanguage } from '@/lib/help'
import { localeFromPath, localeLanguage, unlocalisedPath } from '@/lib/locales'
import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts'
import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' import { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags' import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
@@ -13,9 +15,10 @@ import { fetchWikiPage,
fetchWikiPageByTitle, fetchWikiPageByTitle,
fetchWikiPages } from '@/lib/wiki' fetchWikiPages } from '@/lib/wiki'
import type { LocaleCode } from '@/lib/locales'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> type Prefetcher = (qc: QueryClient, url: URL, locale: LocaleCode) => Promise<void>
const mPost = match<{ id: string }> ('/posts/:id') const mPost = match<{ id: string }> ('/posts/:id')
const mWiki = match<{ title: string }> ('/wiki/:title') const mWiki = match<{ title: string }> ('/wiki/:title')
@@ -28,25 +31,27 @@ const boolFromQuery = (value: string | null): boolean =>
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ()) ['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
const prefetchHelpIndex: Prefetcher = async qc => { const prefetchHelpIndex: Prefetcher = async (qc, url, locale) => {
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: helpKeys.index ('ja'), queryKey: helpKeys.index (localeLanguage (locale)),
queryFn: () => getHelpSearchEntries ('ja'), queryFn: () => getHelpSearchEntries (localeLanguage (locale)),
staleTime: Infinity }) staleTime: Infinity })
} }
const prefetchHelpShow: Prefetcher = async (qc, url) => { const prefetchHelpShow: Prefetcher = async (qc, url, locale) => {
const matched = mHelp (url.pathname) const matched = mHelp (url.pathname)
if (!(matched)) if (!(matched))
return return
const { identifier } = matched.params const { identifier } = matched.params
const language = 'ja'
const article = await qc.fetchQuery ({ const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier), queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier), queryFn: () => getHelpArticle (identifier),
staleTime: Infinity }) staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
if (edition == null) if (edition == null)
return return
@@ -58,15 +63,15 @@ const prefetchHelpShow: Prefetcher = async (qc, url) => {
} }
const prefetchHelpChanges: Prefetcher = async qc => { const prefetchHelpChanges: Prefetcher = async (qc, url, locale) => {
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: helpKeys.changes ('ja'), queryKey: helpKeys.changes (localeLanguage (locale)),
queryFn: () => getHelpChanges ('ja'), queryFn: () => getHelpChanges (localeLanguage (locale)),
staleTime: Infinity }) staleTime: Infinity })
} }
const prefetchHelpDiff: Prefetcher = async (qc, url) => { const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
const matched = mHelpDiff (url.pathname) const matched = mHelpDiff (url.pathname)
const fromValues = url.searchParams.getAll ('from') const fromValues = url.searchParams.getAll ('from')
const toValues = url.searchParams.getAll ('to') const toValues = url.searchParams.getAll ('to')
@@ -76,23 +81,30 @@ const prefetchHelpDiff: Prefetcher = async (qc, url) => {
return return
const { identifier } = matched.params const { identifier } = matched.params
const article = await qc.fetchQuery ({
queryKey: helpKeys.article (identifier),
queryFn: () => getHelpArticle (identifier),
staleTime: Infinity })
const language = resolveHelpLanguage (article, localeLanguage (locale))
if (language == null)
return
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: helpKeys.diff (identifier, 'ja', from, to), queryKey: helpKeys.diff (identifier, language, from, to),
queryFn: () => getHelpDiff (identifier, 'ja', from, to), queryFn: () => getHelpDiff (identifier, language, from, to),
staleTime: Infinity }) staleTime: Infinity })
} }
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ title }), queryKey: wikiKeys.index ({ title }, locale),
queryFn: () => fetchWikiPages ({ title }) }) queryFn: () => fetchWikiPages ({ title }, locale) })
} }
const prefetchWikiPageShow: Prefetcher = async (qc, url) => { const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
const m = mWiki (url.pathname) const m = mWiki (url.pathname)
if (!(m)) if (!(m))
return return
@@ -101,29 +113,29 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
const version = url.searchParams.get ('version') || undefined const version = url.searchParams.get ('version') || undefined
const wikiPage = await qc.fetchQuery ({ const wikiPage = await qc.fetchQuery ({
queryKey: wikiKeys.show (title, { version }), queryKey: wikiKeys.show (title, { version }, locale),
queryFn: () => fetchWikiPageByTitle (title, { version }) }) queryFn: () => fetchWikiPageByTitle (title, { version }, locale) })
if (wikiPage) if (wikiPage)
{ {
const effectiveId = String (wikiPage.id ?? '') const effectiveId = String (wikiPage.id ?? '')
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: wikiKeys.show (effectiveId, { }), queryKey: wikiKeys.show (effectiveId, { }, locale),
queryFn: () => fetchWikiPage (effectiveId, { } ) }) queryFn: () => fetchWikiPage (effectiveId, { }, locale ) })
if (wikiPage.body) if (wikiPage.body)
{ {
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: wikiKeys.index ({ }), queryKey: wikiKeys.index ({ }, locale),
queryFn: () => fetchWikiPages ({ }) }) queryFn: () => fetchWikiPages ({ }, locale) })
} }
} }
const effectiveTitle = wikiPage?.title ?? title const effectiveTitle = wikiPage?.title ?? title
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.show (effectiveTitle), queryKey: tagsKeys.show (effectiveTitle, locale),
queryFn: () => fetchTagByName (effectiveTitle) }) queryFn: () => fetchTagByName (effectiveTitle, locale) })
if (version) if (version)
return return
@@ -143,12 +155,12 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
updatedTo: '', updatedTo: '',
order: 'original_created_at:desc' } as const order: 'original_created_at:desc' } as const
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.index (p), queryKey: postsKeys.index (p, locale),
queryFn: () => fetchPosts (p) }) queryFn: () => fetchPosts (p, locale) })
} }
const prefetchPostsIndex: Prefetcher = async (qc, url) => { const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
const tags = url.searchParams.get ('tags') ?? '' const tags = url.searchParams.get ('tags') ?? ''
const qURL = url.searchParams.get ('url') ?? '' const qURL = url.searchParams.get ('url') ?? ''
const title = url.searchParams.get ('title') ?? '' const title = url.searchParams.get ('title') ?? ''
@@ -169,12 +181,12 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => {
updatedFrom, updatedTo, order } updatedFrom, updatedTo, order }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.index (keys), queryKey: postsKeys.index (keys, locale),
queryFn: () => fetchPosts (keys) }) queryFn: () => fetchPosts (keys, locale) })
} }
const prefetchPostShow: Prefetcher = async (qc, url) => { const prefetchPostShow: Prefetcher = async (qc, url, locale) => {
const m = mPost (url.pathname) const m = mPost (url.pathname)
if (!(m)) if (!(m))
return return
@@ -182,12 +194,12 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
const { id } = m.params const { id } = m.params
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.show (id), queryKey: postsKeys.show (id, locale),
queryFn: () => fetchPost (id) }) queryFn: () => fetchPost (id, locale) })
} }
const prefetchPostChanges: Prefetcher = async (qc, url) => { const prefetchPostChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id') const id = url.searchParams.get ('id')
const tag = url.searchParams.get ('tag') const tag = url.searchParams.get ('tag')
const externalTag = url.searchParams.get ('external_tag') const externalTag = url.searchParams.get ('external_tag')
@@ -197,23 +209,23 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
if (tag) if (tag)
{ {
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.show (tag), queryKey: tagsKeys.show (tag, locale),
queryFn: () => fetchTag (tag) }) queryFn: () => fetchTag (tag, locale) })
} }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: postsKeys.changes ({ ...(id && { id }), queryKey: postsKeys.changes ({ ...(id && { id }),
...(tag && { tag }), ...(tag && { tag }),
...(externalTag && { externalTag }), ...(externalTag && { externalTag }),
page, limit }), page, limit }, locale),
queryFn: () => fetchPostChanges ({ ...(id && { id }), queryFn: () => fetchPostChanges ({ ...(id && { id }),
...(tag && { tag }), ...(tag && { tag }),
...(externalTag && { externalTag }), ...(externalTag && { externalTag }),
page, limit }) }) page, limit }, locale) })
} }
const prefetchTagsIndex: Prefetcher = async (qc, url) => { const prefetchTagsIndex: Prefetcher = async (qc, url, locale) => {
const postRaw = url.searchParams.get ('post') const postRaw = url.searchParams.get ('post')
const post = postRaw ? Number (postRaw) : null const post = postRaw ? Number (postRaw) : null
const name = url.searchParams.get ('name') ?? '' const name = url.searchParams.get ('name') ?? ''
@@ -237,12 +249,12 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
updatedFrom, updatedTo, deprecated, page, limit, order } updatedFrom, updatedTo, deprecated, page, limit, order }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.index (keys), queryKey: tagsKeys.index (keys, locale),
queryFn: () => fetchTags (keys) }) queryFn: () => fetchTags (keys, locale) })
} }
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => { const prefetchNicoTagsIndex: Prefetcher = async (qc, url, locale) => {
const keys = { const keys = {
name: url.searchParams.get ('name') ?? '', name: url.searchParams.get ('name') ?? '',
linkedTag: url.searchParams.get ('linked_tag') ?? '', linkedTag: url.searchParams.get ('linked_tag') ?? '',
@@ -255,12 +267,12 @@ const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
| 'updated_at:asc' | 'updated_at:desc' } | 'updated_at:asc' | 'updated_at:desc' }
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.nicoIndex (keys), queryKey: tagsKeys.nicoIndex (keys, locale),
queryFn: () => fetchNicoTags (keys) }) queryFn: () => fetchNicoTags (keys, locale) })
} }
const prefetchTagShow: Prefetcher = async (qc, url) => { const prefetchTagShow: Prefetcher = async (qc, url, locale) => {
const m = mTag (url.pathname) const m = mTag (url.pathname)
if (!(m)) if (!(m))
return return
@@ -268,19 +280,19 @@ const prefetchTagShow: Prefetcher = async (qc, url) => {
const { id } = m.params const { id } = m.params
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.show (id), queryKey: tagsKeys.show (id, locale),
queryFn: () => fetchTag (id) }) queryFn: () => fetchTag (id, locale) })
} }
const prefetchTagChanges: Prefetcher = async (qc, url) => { const prefetchTagChanges: Prefetcher = async (qc, url, locale) => {
const id = url.searchParams.get ('id') const id = url.searchParams.get ('id')
const page = Number (url.searchParams.get ('page') || 1) const page = Number (url.searchParams.get ('page') || 1)
const limit = Number (url.searchParams.get ('limit') || 20) const limit = Number (url.searchParams.get ('limit') || 20)
await qc.prefetchQuery ({ await qc.prefetchQuery ({
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }), queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) }) queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }, locale) })
} }
@@ -313,9 +325,14 @@ export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[]
export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => {
const u = new URL (urlLike, location.origin) const u = new URL (urlLike, location.origin)
if (u.origin !== location.origin)
return
const locale = localeFromPath (u.pathname) ?? 'ja'
u.pathname = unlocalisedPath (u.pathname)
const r = routePrefetchers.find (x => x.test (u)) const r = routePrefetchers.find (x => x.test (u))
if (!(r)) if (!(r))
return return
await r.run (qc, u) await r.run (qc, u, locale)
} }
+101 -30
ファイルの表示
@@ -1,3 +1,4 @@
import type { LocaleCode } from '@/lib/locales'
import type { import type {
FetchNicoTagsParams, FetchNicoTagsParams,
FetchMaterialsParams, FetchMaterialsParams,
@@ -6,61 +7,131 @@ import type {
MaterialFilter, MaterialFilter,
} from '@/types' } from '@/types'
export const localesKeys = {
root: ['locales'] as const,
index: ['locales', 'index'] as const }
export const postsKeys = { export const postsKeys = {
root: ['posts'] as const, root: ['posts'] as const,
index: (p: FetchPostsParams) => ['posts', 'index', p] as const, index: (
show: (id: string) => ['posts', id] as const, p: FetchPostsParams,
related: (id: string) => ['related', id] as const, locale: LocaleCode = 'ja',
changes: (p: { post?: string ) => ['posts', locale, 'index', p] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['posts', locale, id] as const,
related: (
id: string,
locale: LocaleCode = 'ja',
) => ['related', locale, id] as const,
changes: (
p: { post?: string
tag?: string tag?: string
externalTag?: string externalTag?: string
page: number page: number
limit: number }) => limit: number },
['posts', 'changes', p] as const } locale: LocaleCode = 'ja',
) =>
['posts', locale, 'changes', p] as const }
export const gekanatorKeys = { export const gekanatorKeys = {
root: ['gekanator'] as const, root: ['gekanator'] as const,
posts: () => ['gekanator', 'posts'] as const, posts: (
questions: () => ['gekanator', 'questions'] as const, locale: LocaleCode = 'ja',
extraQuestions: (gameId: number, nonce: string) => ) => ['gekanator', locale, 'posts'] as const,
['gekanator', 'games', gameId, 'extra-questions', nonce] as const } questions: (
locale: LocaleCode = 'ja',
) => ['gekanator', locale, 'questions'] as const,
extraQuestions: (
gameId: number,
nonce: string,
locale: LocaleCode = 'ja',
) =>
['gekanator', locale, 'games', gameId, 'extra-questions', nonce] as const }
export const tagsKeys = { export const tagsKeys = {
root: ['tags'] as const, root: ['tags'] as const,
index: (p: FetchTagsParams) => ['tags', 'index', p] as const, index: (
p: FetchTagsParams,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'index', p] as const,
nicoRoot: ['tags', 'nico'] as const, nicoRoot: ['tags', 'nico'] as const,
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const, nicoIndex: (
externalShow: (id: string) => ['tags', 'nico', id] as const, p: FetchNicoTagsParams,
show: (name: string) => ['tags', name] as const, locale: LocaleCode = 'ja',
changes: (p: { id?: string; page: number; limit: number }) => ) => ['tags', locale, 'nico', 'index', p] as const,
['tags', 'changes', p] as const, externalShow: (
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const } id: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'nico', id] as const,
show: (
name: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, name] as const,
changes: (
p: { id?: string; page: number; limit: number },
locale: LocaleCode = 'ja',
) =>
['tags', locale, 'changes', p] as const,
deerjikists: (
id: string,
locale: LocaleCode = 'ja',
) => ['tags', locale, 'deerjikists', id] as const }
export const materialsKeys = { export const materialsKeys = {
root: ['materials'] as const, root: ['materials'] as const,
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const, index: (
changes: (p: { p: FetchMaterialsParams,
locale: LocaleCode = 'ja',
) => ['materials', locale, 'index', p] as const,
changes: (
p: {
materialId?: string materialId?: string
tag?: string tag?: string
eventType?: string eventType?: string
page: number page: number
limit: number limit: number
}) => ['materials', 'changes', p] as const, },
byTagName: (name: string, materialFilter: MaterialFilter) => locale: LocaleCode = 'ja',
['materials', 'tag', name, materialFilter] as const, ) => ['materials', locale, 'changes', p] as const,
show: (id: string) => ['materials', id] as const, byTagName: (
suppressions: () => ['materials', 'suppressions'] as const, name: string,
tree: (p: { materialFilter: MaterialFilter,
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'tag', name, materialFilter] as const,
show: (
id: string,
locale: LocaleCode = 'ja',
) => ['materials', locale, id] as const,
suppressions: (
locale: LocaleCode = 'ja',
) => ['materials', locale, 'suppressions'] as const,
tree: (
p: {
parentId?: number | null parentId?: number | null
materialFilter: MaterialFilter materialFilter: MaterialFilter
}) => ['materials', 'tree', p] as const, },
unclassified: (p: { page?: number; limit?: number } = { }) => locale: LocaleCode = 'ja',
['materials', 'unclassified', p] as const} ) => ['materials', locale, 'tree', p] as const,
unclassified: (
p: { page?: number; limit?: number } = { },
locale: LocaleCode = 'ja',
) =>
['materials', locale, 'unclassified', p] as const}
export const wikiKeys = { export const wikiKeys = {
root: ['wiki'] as const, root: ['wiki'] as const,
index: (p: { title?: string }) => ['wiki', 'index', p] as const, index: (
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const } p: { title?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, 'index', p] as const,
show: (
title: string,
p: { version?: string },
locale: LocaleCode = 'ja',
) => ['wiki', locale, title, p] as const }
export const helpKeys = { export const helpKeys = {
root: ['help'] as const, root: ['help'] as const,
+13
ファイルの表示
@@ -0,0 +1,13 @@
import { matchPath } from 'react-router-dom'
import { unlocalisedPath } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
const PAGE_LOCALES: { path: string; locale: LocaleCode }[] = [
{ path: '/gekanator', locale: 'ja' }]
export const requiredPageLocale = (pathname: string): LocaleCode | null =>
PAGE_LOCALES.find (page =>
matchPath ({ path: page.path, end: true }, unlocalisedPath (pathname)))?.locale ?? null
+32 -14
ファイルの表示
@@ -2,6 +2,7 @@ import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { Deerjikist, import type { Deerjikist,
FetchNicoTagsParams, FetchNicoTagsParams,
FetchTagsParams, FetchTagsParams,
@@ -13,9 +14,11 @@ import type { Deerjikist,
export const fetchTags = async ( export const fetchTags = async (
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo, { post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
updatedFrom, updatedTo, deprecated, updatedFrom, updatedTo, deprecated,
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[] page, limit, order }: FetchTagsParams,
locale: LocaleCode = 'ja',
): Promise<{ tags: Tag[]
count: number }> => count: number }> =>
await apiGet ('/tags', { params: { await apiGet ('/tags', { locale, params: {
...(post != null && { post }), ...(post != null && { post }),
...(name && { name }), ...(name && { name }),
...(category && { category }), ...(category && { category }),
@@ -32,9 +35,11 @@ export const fetchTags = async (
export const fetchNicoTags = async ( export const fetchNicoTags = async (
{ name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams): Promise<{ tags: NicoTag[] { name, linkedTag, linkStatus, page, limit, order }: FetchNicoTagsParams,
locale: LocaleCode = 'ja',
): Promise<{ tags: NicoTag[]
count: number }> => count: number }> =>
await apiGet ('/tags/nico', { params: { await apiGet ('/tags/nico', { locale, params: {
page, page,
limit, limit,
name, name,
@@ -43,10 +48,13 @@ export const fetchNicoTags = async (
order } }) order } })
export const fetchTag = async (id: string): Promise<Tag | null> => { export const fetchTag = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try try
{ {
return await apiGet (`/tags/${ id }`) return await apiGet (`/tags/${ id }`, { locale })
} }
catch catch
{ {
@@ -55,10 +63,13 @@ export const fetchTag = async (id: string): Promise<Tag | null> => {
} }
export const fetchExternalTag = async (id: string): Promise<Tag | null> => { export const fetchExternalTag = async (
id: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try try
{ {
return await apiGet (`/tags/nico/${ id }`) return await apiGet (`/tags/nico/${ id }`, { locale })
} }
catch catch
{ {
@@ -67,10 +78,13 @@ export const fetchExternalTag = async (id: string): Promise<Tag | null> => {
} }
export const fetchTagByName = async (name: string): Promise<Tag | null> => { export const fetchTagByName = async (
name: string,
locale: LocaleCode = 'ja',
): Promise<Tag | null> => {
try try
{ {
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`) return await apiGet (`/tags/name/${ encodeURIComponent (name) }`, { locale })
} }
catch (error) catch (error)
{ {
@@ -84,12 +98,16 @@ export const fetchTagChanges = async (
{ id, page, limit }: { { id, page, limit }: {
id?: string id?: string
page: number page: number
limit: number }): Promise<{ limit: number },
locale: LocaleCode = 'ja',
): Promise<{
versions: TagVersion[] versions: TagVersion[]
count: number }> => count: number }> =>
await apiGet ('/tags/versions', { params: { ...(id && { id }), page, limit } }) await apiGet ('/tags/versions', { locale, params: { ...(id && { id }), page, limit } })
export const fetchDeerjikistsByTag = async ( export const fetchDeerjikistsByTag = async (
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> => id: string,
await apiGet (`/tags/${ id }/deerjikists`) locale: LocaleCode = 'ja',
): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
await apiGet (`/tags/${ id }/deerjikists`, { locale })
+3 -2
ファイルの表示
@@ -7,8 +7,9 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useLocaleNavigate } from '@/lib/useLocale'
import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog' import ShortcutHelpDialog from '@/components/users/ShortcutHelpDialog'
import { import {
DEFAULT_KEY_BINDINGS, DEFAULT_KEY_BINDINGS,
@@ -95,7 +96,7 @@ const focusSearchTarget = (): void => {
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => { export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
const location = useLocation () const location = useLocation ()
const navigate = useNavigate () const navigate = useLocaleNavigate ()
const [keyboardSettings, setKeyboardSettingsState] = const [keyboardSettings, setKeyboardSettingsState] =
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ()) useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
+27
ファイルの表示
@@ -0,0 +1,27 @@
import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { isLocaleCode, localeFromPath, localiseTo } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
import type { LocaleCode } from '@/lib/locales'
export const useLocale = (): LocaleCode => {
const { locale } = useParams ()
const location = useLocation ()
return isLocaleCode (locale) ? locale : localeFromPath (location.pathname) ?? 'ja'
}
export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate ()
const locale = useLocale ()
return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number')
navigate (to)
else
navigate (localiseTo (to, locale), options)
}, [locale, navigate])
}
+24
ファイルの表示
@@ -0,0 +1,24 @@
import { createElement } from 'react'
import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => {
const locale = useLocale ()
const { t } = useTranslation (namespace, { lng: locale })
const message = (key: string, values?: Record<string, string | number>) => {
const result = t (key, { ...values, returnDetails: true })
return { text: result.res, language: result.usedLng }
}
const label = (key: string, values?: Record<string, string | number>) => {
const result = message (key, values)
return createElement (LocalisedText, {
language: result.language, children: result.text })
}
return { message, label }
}
+20
ファイルの表示
@@ -3,6 +3,8 @@ import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx' import type { ClassValue } from 'clsx'
import type { LocaleCode } from '@/lib/locales'
export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d export const toDate = (d: string | Date): Date => typeof d === 'string' ? new Date (d) : d
@@ -25,6 +27,24 @@ export const dateString = (
second: unknown == null ? 'numeric' : undefined }) second: unknown == null ? 'numeric' : undefined })
export const localisedDateString = (value: string | Date, locale: LocaleCode): string => {
if (locale === 'ja')
return dateString (value)
const date = toDate (value)
if (Number.isNaN (date.getTime ()))
return dateString (value)
const parts = new Intl.DateTimeFormat ('en-CA', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hourCycle: 'h23' }).formatToParts (date)
const part = (type: string) => parts.find (entry => entry.type === type)?.value ?? ''
return `${ part ('year') }-${ part ('month') }-${ part ('day') }\u3000${
part ('hour') }:${ part ('minute') }:${ part ('second') }`
}
export const originalCreatedAtString = ( export const originalCreatedAtString = (
f: string | Date | null, f: string | Date | null,
b: string | Date | null): string => { b: string | Date | null): string => {
+13 -6
ファイルの表示
@@ -2,26 +2,33 @@ import { isAxiosError } from 'axios'
import { apiGet } from '@/lib/api' import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { WikiPage } from '@/types' import type { WikiPage } from '@/types'
export const fetchWikiPages = async ( export const fetchWikiPages = async (
{ title }: { title?: string }): Promise<WikiPage[]> => { title }: { title?: string },
await apiGet ('/wiki', { params: { title } }) locale: LocaleCode = 'ja',
): Promise<WikiPage[]> =>
await apiGet ('/wiki', { locale, params: { title } })
export const fetchWikiPage = async ( export const fetchWikiPage = async (
id: string, id: string,
{ version }: { version?: string }): Promise<WikiPage> => { version }: { version?: string },
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } }) locale: LocaleCode = 'ja',
): Promise<WikiPage> =>
await apiGet (`/wiki/${ id }`, { locale, params: version ? { version } : { } })
export const fetchWikiPageByTitle = async ( export const fetchWikiPageByTitle = async (
title: string, title: string,
{ version }: { version?: string }): Promise<WikiPage | null> => { { version }: { version?: string },
locale: LocaleCode = 'ja',
): Promise<WikiPage | null> => {
try try
{ {
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
} }
catch (error) catch (error)
{ {
+23
ファイルの表示
@@ -0,0 +1,23 @@
{ "countLabel": "チピㇱキㇷ゚",
"categoryLabel": "イソネカ",
"stateLabel": "シㇼキ",
"search": "イフナラ",
"loading": "タツ゚ㇺ アウㇰ コㇿ アン…",
"fetchError": "タツ゚ㇺ アウㇰ エアイカㇷ゚",
"states": { "active": "アㇱカイ",
"deprecated": "タネ ソモ アエイワンケ" },
"categories": { "deerjikist": "ユㇰ ニチカ ウタㇻ",
"meme": "MEME",
"character": "イノカ",
"general": "ウサ オルㇱペ",
"material": "アカㇻペ",
"meta": "タㇰ オルㇱペ タㇰ",
"nico": "ニコニコ タㇰ" },
"pagination": { "label": "ソㇱ",
"first": "イヨッタ ホㇱキ ソㇱ",
"previous": "ホㇱキ ソㇱ",
"next": "トゥタヌ ソㇱ",
"last": "イヨッタ イヨㇱノ アン ソㇱ" },
"tagLink": { "missingMaterial": "{{name}}:チカㇻペ アスㇽソㇱ ソモ アン。",
"missingDeerjikist": "{{name}}:アスㇽソㇱ ソモ アン。",
"missingWiki": "{{name}}:Wiki ソモ アン。" } }
+17
ファイルの表示
@@ -0,0 +1,17 @@
{ "403": {
"message": "エエイワンケ エアイカㇷ゚",
"right": "ホㇱピ ヤン!",
"left": "ホㇱピ ヤン!" },
"404": {
"message": "タン ソㇱ イサㇺ",
"right": "ホㇱピ ヤン!",
"left": "ホㇱピ ヤン!" },
"500": {
"message": "サーバー オㇿ ハイタ アン",
"right": "カンリニン ウン",
"left": "エイェ ヤン!" },
"503": {
"message": "サーバー アㇱカイ ソモ キ",
"right": "カンリニン ウン",
"left": "エイェ ヤン!" },
"imageAlt": "キタ イクヨ" }
+7
ファイルの表示
@@ -0,0 +1,7 @@
{ "title": "ヤイカオパㇱ",
"search": "ヤイカオパㇱ イフナラ",
"history": "ヤイカオパㇱ アシㇼ カㇻ フㇱコアンペ",
"effectiveSince": "アㇱカイ ヘㇻパ ト",
"empty": "イフナラ ワ ネ ペ イサㇺ。",
"diffTitle": "ヤイカオパㇱ ウシンナイペ:{{title}}",
"articleTitle": "{{title}} ヤイカオパㇱ" }
+7
ファイルの表示
@@ -0,0 +1,7 @@
{ "status": {
"ready": "アオ エアㇱカイ",
"error": "アオ エアイカㇷ゚",
"warning": "イパㇰカㇱヌ",
"skipped": "スキップ",
"created": "アオ オケレ",
"failed": "アオ カ エアイカㇷ゚" } }
+25
ファイルの表示
@@ -0,0 +1,25 @@
{ "posts": "ミンタㇻ",
"postsWithCount": "ミンタㇻ({{count}})",
"list": "オピッタ",
"search": "イフナラ",
"add": "アオ",
"history": "フㇱコアンペ",
"allHistory": "フㇱコアンペ オピッタ",
"help": "ヤイカオパㇱ",
"tags": "タㇰ",
"master": "オピッタ",
"nicoLinks": "ニコニコ ウコテㇷ゚",
"materials": "チカㇻペ",
"suppressions": "アエアイカㇷ゚テㇷ゚",
"wiki": "Wiki",
"create": "アシㇼ",
"edit": "イヌイェㇷ゚",
"entertainment": "シノッ",
"theatre": "チモイェノカ ヌカㇻ ウウェカㇻ(β)",
"gekanator": "Gekanator(β)",
"users": "イイェイワンケクㇽ",
"currentUser": "エアニ",
"settings": "イアヌ",
"legal": "イレンカ",
"terms": "エイワンケ イレンカ",
"more": "モㇱマ アン ペ »" }
+60
ファイルの表示
@@ -0,0 +1,60 @@
{ "labels": {
"title": "レ",
"content": "オルㇱペ",
"body": "ヌイェㇷ゚",
"lastUpdated": "イヨッタ アシㇼ アカㇻ ト",
"thumbnail": "ポン ノカ",
"noThumbnail": "ポン ノカ イサㇺ",
"thumbnailMissing": "ポン ノカ イサㇺ",
"parentPosts": "オナ アオ ペ",
"duration": "チモイェノカ トキ",
"platform": "プラットフォーム",
"code": "コード",
"information": "アスㇽソㇱ",
"relations": "ウコテㇷ゚",
"uploadedBy": "カㇻ クㇽ:",
"uploadedAt": "アカㇻ ト:",
"link": "ウコテㇷ゚:",
"originalPostedAt": "ホㇱキ アオ ト:",
"originalCreatedAt": "ホㇱキ アカㇻ ト:",
"brand": "ポチ チャ ロㇰ シクヌㇷ゚ イキㇼ チセ",
"since": "オㇿ ワノ",
"before": "エトコ ウン",
"tagSearch": "タㇰ イフナラ…",
"hideTags": "▲▲▲ タㇰ ソモ ヌカㇻ ▲▲▲",
"showTags": "▼▼▼ タㇰ ヌカㇻ ▼▼▼",
"resultsCount": "{{count}} ペ",
"menu": "メニュー",
"anonymous": "レサㇰ ユㇰ ニチカ ウタㇻ",
"bot": "bot カㇻ",
"deprecatedSuffix": "(エイワンケ ソモ エアㇱカイ)",
"development": "カㇻ コㇿ アン シㇼキ" },
"actions": {
"update": "アシㇼ カㇻ",
"edit": "イヌイェㇷ゚",
"add": "アオ",
"reset": "ホㇱキ シㇼ ウン ホㇱピ",
"cancel": "ソモ キ",
"retry": "スイ キ",
"skip": "スキップ",
"random": "ランダム",
"old": "< フㇱコ",
"new": "アシㇼ >",
"oldest": "(イヨッタ フㇱコ)",
"latest": "(イヨッタ アシㇼ)",
"diff": "ウシンナイペ",
"restore": "ホㇱピ",
"show": "ヌカㇻ" },
"messages": {
"loading": "アウㇰ コㇿ アン…",
"loadingEllipsis": "アウㇰ コㇿ アン……",
"updated": "アシㇼ アカㇻ!",
"updateFailed": "アシㇼ カㇻ カ エアイカㇷ゚",
"failed": "エアイカㇷ゚……",
"checkInput": "エヌイェㇷ゚ エヌカㇻ ヤン!",
"checkInputModern": "エヌイェㇷ゚ エヌカㇻ ヤン。",
"checkInputContent": "エヌイェ オルㇱペ エヌカㇻ ヤン!",
"reviewInput": "エヌイェㇷ゚ スイ エヌカㇻ ヤン!",
"postSuccess": "アオ オケレ!",
"postFailed": "アオ カ エアイカㇷ゚",
"resetChanges": "エヌイェㇷ゚ ホㇱキ シㇼ ウン ホㇱピ ヤ?" } }
+11
ファイルの表示
@@ -0,0 +1,11 @@
{ "title": "タㇰ",
"name": "レ",
"postCount": "ミンタㇻ アオ ペ チピㇱキㇷ゚",
"firstRecordedAt": "ホㇱキ アヌイェ ト ヘㇺ トキ",
"definitionUpdatedAt": "イタㇰ アシㇼ アカㇻ ト ヘㇺ トキ",
"aliases": "モㇱマ レ",
"parents": "サパ タㇰ",
"firstRecordedColumn": "ホㇱキ アヌイェ ト",
"updatedAt": "アシㇼ アカㇻ ト",
"history": "フㇱコアンペ",
"empty": "イフナラ ワ ネ ペ イサㇺ" }
+8
ファイルの表示
@@ -0,0 +1,8 @@
{ "newPage": "アシㇼ ウィキ ソㇱ",
"editPage": "ウィキ ソㇱ イヌイェㇷ゚",
"history": "ウィキ アシㇼ カㇻ フㇱコアンペ",
"historyHeading": "ウィキ フㇱコアンペ",
"change": "アシㇼ カㇻ",
"dateTime": "ト ヘㇺ トキ",
"diffTitle": "ウィキ ウシンナイペ:{{title}}",
"missingPage": "タン ソㇱ イサㇺ。[アシㇼ カㇻ ヤン]({{url}})。" }
+33
ファイルの表示
@@ -0,0 +1,33 @@
{
"countLabel": "件数",
"categoryLabel": "カテゴリ",
"stateLabel": "状態",
"search": "検索",
"loading": "Loading...",
"fetchError": "データ取得エラー",
"states": {
"active": "有効",
"deprecated": "廃止"
},
"categories": {
"deerjikist": "ニジラー",
"meme": "原作・ネタ元・ミーム等",
"character": "キャラクター",
"general": "一般",
"material": "素材",
"meta": "メタタグ",
"nico": "ニコニコタグ"
},
"pagination": {
"label": "Pagination",
"first": "最初のページ",
"previous": "前のページ",
"next": "次のページ",
"last": "最後のページ"
},
"tagLink": {
"missingMaterial": "{{name}} 素材情報が存在しません.",
"missingDeerjikist": "{{name}} に関する情報が存在しません.",
"missingWiki": "{{name}} Wiki が存在しません."
}
}
+7
ファイルの表示
@@ -0,0 +1,7 @@
{
"403": { "message": "権限ないよ(笑)", "right": "帰れ!", "left": "帰れ!" },
"404": { "message": "ページないよ(笑)", "right": "帰れ!", "left": "帰れ!" },
"500": { "message": "鯖でエラー出たって(嘲笑)", "right": "管理人に", "left": "聯絡を!" },
"503": { "message": "鯖死んでるよ(泣)", "right": "管理人に", "left": "聯絡を!" },
"imageAlt": "逃げたギター"
}
+5
ファイルの表示
@@ -0,0 +1,5 @@
{
"title": "ヘルプ", "search": "ヘルプ検索", "history": "ヘルプ改定履歴",
"effectiveSince": "有効開始年月日", "empty": "該当する記事がありません.",
"diffTitle": "ヘルプ 差分: {{title}}", "articleTitle": "{{title}} ヘルプ"
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
{
"status": {
"ready": "登録可能", "error": "登録不可", "warning": "警告",
"skipped": "スキップ", "created": "登録済み", "failed": "登録失敗"
}
}

変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示