コミットを比較
7
コミット
main
...
feature/428
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
0c1f459d4e | ||
|
|
9be0c7b1d9 | ||
|
|
63860c94af | ||
|
|
851d5e8e09 | ||
|
|
91296f7477 | ||
|
|
f063fc54bb | ||
|
|
b945ceef60 |
@@ -1,10 +1,16 @@
|
||||
class ApplicationController < ActionController::API
|
||||
class UnsupportedLocale < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
|
||||
rescue_from ActiveRecord::RecordNotUnique, with: :render_record_not_unique
|
||||
rescue_from(UnsupportedLocale) { head :not_found }
|
||||
|
||||
before_action :reject_banned_ip_address!
|
||||
before_action :authenticate_user
|
||||
before_action :reject_banned_user!
|
||||
before_action :validate_explicit_locale!
|
||||
|
||||
def current_user = @current_user
|
||||
|
||||
@@ -93,5 +99,21 @@ class ApplicationController < ActionController::API
|
||||
value
|
||||
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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class LocalesController < ApplicationController
|
||||
def index
|
||||
locales = Locale.where(deprecated_at: nil).order(:created_at).map do |locale|
|
||||
{ code: locale.code_before_type_cast, name: locale.name }
|
||||
end
|
||||
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
render json: { locales: }
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class PostVersionsController < ApplicationController
|
||||
def index
|
||||
locale = resolve_locale!
|
||||
post_id = params[:post].presence
|
||||
tag_id = params[:tag].presence&.to_i
|
||||
external_tag_id = params[:external_tag].presence&.to_i
|
||||
@@ -45,12 +46,12 @@ class PostVersionsController < ApplicationController
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
render json: { versions: serialise_versions(versions), count: }
|
||||
render json: { versions: serialise_versions(versions, locale.language_code), count: }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialise_versions rows
|
||||
def serialise_versions rows, language_code
|
||||
rows = rows.to_a
|
||||
user_ids = rows.map(&:created_by_user_id).compact.uniq
|
||||
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.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)
|
||||
|
||||
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(
|
||||
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,
|
||||
version_no: row.version_no,
|
||||
@@ -106,17 +111,23 @@ class PostVersionsController < ApplicationController
|
||||
names
|
||||
end
|
||||
|
||||
def snapshot_tag_literals snapshots, external_tag_names
|
||||
def snapshot_tag_literals snapshots, tag_names, external_tag_names
|
||||
snapshots
|
||||
.filter_map { |snapshot|
|
||||
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']),
|
||||
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']]
|
||||
[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
|
||||
}
|
||||
.sort_by { |order, name| [order, name.downcase] }
|
||||
.sort_by { |order, tag| [order, tag[:name].downcase] }
|
||||
.map(&:second)
|
||||
end
|
||||
|
||||
@@ -125,17 +136,20 @@ class PostVersionsController < ApplicationController
|
||||
end
|
||||
|
||||
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 =
|
||||
if cur_tags.include?(name) && prev_tags.include?(name)
|
||||
if current.key?(identity) && previous.key?(identity)
|
||||
'context'
|
||||
elsif cur_tags.include?(name)
|
||||
elsif current.key?(identity)
|
||||
'added'
|
||||
else
|
||||
'removed'
|
||||
end
|
||||
|
||||
{ name:, type: }
|
||||
{ name: current[identity] || previous[identity], type: }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Language < ApplicationRecord
|
||||
has_many :locales, foreign_key: :language_code, primary_key: :code
|
||||
|
||||
enum :code, ja: 'ja'
|
||||
enum :code, ja: 'ja', ain: 'ain'
|
||||
end
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
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 :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')
|
||||
|
||||
private
|
||||
|
||||
def generate_tag_names!
|
||||
ApplicationRecord.transaction do
|
||||
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
|
||||
def needs_tag_names?
|
||||
deprecated_at.nil? && (saved_change_to_code? || saved_change_to_deprecated_at?)
|
||||
end
|
||||
|
||||
def generate_tag_names! = TagNameCompletion.for_locale!(self)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Script < ApplicationRecord
|
||||
has_many :locales, foreign_key: :script_code, primary_key: :code
|
||||
|
||||
enum :code, Jpan: 'Jpan'
|
||||
enum :code, jpan: 'Jpan', kana: 'Kana'
|
||||
end
|
||||
|
||||
+9
-28
@@ -110,7 +110,7 @@ class Tag < ApplicationRecord
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -263,16 +263,19 @@ class Tag < ApplicationRecord
|
||||
next tn.tag if tn
|
||||
|
||||
tag = Tag.create!(category:)
|
||||
tn = TagName.create!(tag:, language_code:, name:,
|
||||
script_code: locale.script_code, primary_flg: true,
|
||||
auto_generated: false)
|
||||
generate_tag_names!(locale, tag, tn)
|
||||
TagName.create!(tag:, language_code:, name:,
|
||||
script_code: locale.script_code, primary_flg: true,
|
||||
auto_generated: false)
|
||||
TagNameCompletion.for_tag!(tag)
|
||||
tag.association(:tag_names).reset
|
||||
|
||||
tag
|
||||
end
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
existing = TagName.find_by(language_code:, name:)
|
||||
raise unless existing
|
||||
|
||||
existing.tag
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
@@ -34,9 +34,15 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
private
|
||||
@@ -54,6 +60,8 @@ class TagName < ApplicationRecord
|
||||
end
|
||||
|
||||
def name_must_be_sanitised
|
||||
return if auto_generated && name == "Tag_##{ tag_id }"
|
||||
|
||||
if name? && name != TagNameSanitisationRule.sanitise(name)
|
||||
errors.add :name, '名前に使用できない文字が含まれてゐます.'
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
module TagNameTranscription
|
||||
class Unconvertible < StandardError
|
||||
;
|
||||
end
|
||||
|
||||
def self.ainu name
|
||||
previous_script = nil
|
||||
parts = []
|
||||
NipponeseSegments.split(name).each do |script, text|
|
||||
output = transcribe_segment(script, text)
|
||||
if (parts.last && output.first && previous_script && script != previous_script &&
|
||||
NipponeseSegments.nipponese?(previous_script) && NipponeseSegments.nipponese?(script))
|
||||
parts << '_'
|
||||
end
|
||||
|
||||
parts.concat(output)
|
||||
previous_script = script
|
||||
end
|
||||
|
||||
replace_unconvertible(parts)
|
||||
end
|
||||
|
||||
def self.transcribe_segment script, text
|
||||
return [text] if script == :literal
|
||||
return [nil] if script == :unconvertible
|
||||
return transcribe_reading(text) unless script == :kanzi
|
||||
|
||||
text.each_char.flat_map do |character|
|
||||
transcribe_reading(JisReadings.read(character))
|
||||
rescue Unconvertible
|
||||
[nil]
|
||||
end
|
||||
end
|
||||
|
||||
def self.transcribe_reading reading
|
||||
Phonology.adapt(reading).map do |sound|
|
||||
sound && Katakana.render([sound])
|
||||
rescue Unconvertible
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def self.replace_unconvertible parts
|
||||
grouped = parts.chunk { _1.nil? }.flat_map { |unconvertible, group|
|
||||
unconvertible ? [nil] : group
|
||||
}
|
||||
first = grouped.index { _1&.match?(/[^_]/) }
|
||||
last = grouped.rindex { _1&.match?(/[^_]/) }
|
||||
|
||||
grouped.each_with_index.map { |part, index|
|
||||
part || (first && first < index && index < last ? '_' : '')
|
||||
}.join
|
||||
end
|
||||
|
||||
private_class_method :transcribe_segment, :transcribe_reading, :replace_unconvertible
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module TagNameTranscription
|
||||
module JisReadings
|
||||
SOURCE = Rails.root.join('config/tag_name_readings/jis_level_one.tsv')
|
||||
READINGS = File.foreach(SOURCE, chomp: true).reject { _1.start_with?('#') }.to_h { |line|
|
||||
character, reading, = line.split("\t")
|
||||
[character, reading]
|
||||
}.freeze
|
||||
|
||||
def self.read text
|
||||
text.each_char.map { READINGS.fetch(_1) { raise Unconvertible } }.join
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
module TagNameTranscription
|
||||
module Katakana
|
||||
ROWS = {
|
||||
'' => 'アイウエオ',
|
||||
'k' => 'カキクケコ',
|
||||
's' => 'サシスセソ',
|
||||
'c' => ['チャ', 'チ', 'チュ', 'チェ', 'チョ'],
|
||||
't' => ['タ', 'ティ', 'トゥ', 'テ', 'ト'],
|
||||
'n' => 'ナニヌネノ',
|
||||
'h' => 'ハヒフヘホ',
|
||||
'p' => 'パピプペポ',
|
||||
'm' => 'マミムメモ',
|
||||
'r' => 'ラリルレロ' }.freeze
|
||||
SYLLABLES = ROWS.flat_map { |onset, letters|
|
||||
kana = letters.is_a?(String) ? letters.chars : letters
|
||||
['a', 'i', 'u', 'e', 'o'].zip(kana).map { |vowel, letter| [onset + vowel, letter] }
|
||||
}.to_h.merge(
|
||||
'ya' => 'ヤ', 'yu' => 'ユ', 'yo' => 'ヨ', 'wa' => 'ワ',
|
||||
'k' => 'ㇰ', 's' => 'ㇱ', 't' => 'ッ', 'n' => 'ン', 'm' => 'ㇺ', 'p' => 'ㇷ゚',
|
||||
'r:a' => 'ㇻ', 'r:i' => 'ㇼ', 'r:u' => 'ㇽ', 'r:e' => 'ㇾ', 'r:o' => 'ㇿ').freeze
|
||||
|
||||
def self.render sounds
|
||||
sounds.map { SYLLABLES.fetch(_1) { raise Unconvertible } }.join
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
module TagNameTranscription
|
||||
module NipponeseSegments
|
||||
NIPPONESE_SCRIPTS = [:kanzi, :xiragana, :katakana].freeze
|
||||
|
||||
def self.nipponese?(script) = NIPPONESE_SCRIPTS.include?(script)
|
||||
|
||||
def self.split name
|
||||
segments = []
|
||||
name.to_s.scan(/\X/).each do |character|
|
||||
script = classify(character, segments.last&.first)
|
||||
if segments.last&.first == script
|
||||
segments.last[1] << character
|
||||
else
|
||||
segments << [script, character.dup]
|
||||
end
|
||||
end
|
||||
segments
|
||||
end
|
||||
|
||||
def self.classify character, previous_script
|
||||
return previous_script if character == 'ー' && nipponese?(previous_script)
|
||||
return :kanzi if character.match?(/\A\p{Han}\z/)
|
||||
return :xiragana if character.match?(/\A\p{Hiragana}\p{M}*\z/)
|
||||
return :katakana if character.match?(/\A\p{Katakana}\p{M}*\z/)
|
||||
return :literal if character.match?(/\A(?:\p{Latin}\p{M}*|[!-~])\z/)
|
||||
|
||||
:unconvertible
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
module TagNameTranscription
|
||||
module Phonology
|
||||
ROWS = {
|
||||
'アイウエオ' => ['a', 'i', 'u', 'e', 'o'],
|
||||
'カキクケコ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||
'ガギグゲゴ' => ['ka', 'ki', 'ku', 'ke', 'ko'],
|
||||
'サシスセソ' => ['sa', 'si', 'su', 'se', 'so'],
|
||||
'ザジズゼゾ' => ['ca', 'ci', 'cu', 'ce', 'co'],
|
||||
'タチツテト' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||
'ダヂヅデド' => ['ta', 'ci', 'cu', 'te', 'to'],
|
||||
'ナニヌネノ' => ['na', 'ni', 'nu', 'ne', 'no'],
|
||||
'ハヒフヘホ' => ['ha', 'hi', 'hu', 'he', 'ho'],
|
||||
'バビブベボ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||
'パピプペポ' => ['pa', 'pi', 'pu', 'pe', 'po'],
|
||||
'マミムメモ' => ['ma', 'mi', 'mu', 'me', 'mo'],
|
||||
'ヤユヨ' => ['ya', 'yu', 'yo'],
|
||||
'ラリルレロ' => ['ra', 'ri', 'ru', 're', 'ro'],
|
||||
'ワ' => ['wa'],
|
||||
'ン' => ['n'],
|
||||
'ッ' => ['t'],
|
||||
'ㇰ' => ['k'],
|
||||
'ㇱㇲ' => ['s', 's'],
|
||||
'ㇳ' => ['t'],
|
||||
'ㇴ' => ['n'],
|
||||
'ㇺ' => ['m'],
|
||||
'ㇻㇼㇽㇾㇿ' => ['r:a', 'r:i', 'r:u', 'r:e', 'r:o'] }.freeze
|
||||
|
||||
SOUNDS = ROWS.flat_map { |characters, sounds| characters.chars.zip(sounds) }.to_h.freeze
|
||||
|
||||
DIGRAPHS = {
|
||||
'シャ' => 'sa', 'シュ' => 'su', 'シェ' => 'se', 'ショ' => 'so',
|
||||
'チャ' => 'ca', 'チュ' => 'cu', 'チェ' => 'ce', 'チョ' => 'co',
|
||||
'ジャ' => 'ca', 'ジュ' => 'cu', 'ジェ' => 'ce', 'ジョ' => 'co',
|
||||
'ヂャ' => 'ca', 'ヂュ' => 'cu', 'ヂェ' => 'ce', 'ヂョ' => 'co' }.freeze
|
||||
|
||||
def self.adapt reading
|
||||
kana = reading.unicode_normalize(:nfc).tr('ぁ-ゖ', 'ァ-ヶ').delete('ー')
|
||||
characters = kana.scan(/\X/)
|
||||
sounds = []
|
||||
index = 0
|
||||
|
||||
while index < characters.length
|
||||
pair = characters[index, 2].join
|
||||
|
||||
if DIGRAPHS.key?(pair)
|
||||
sounds << DIGRAPHS.fetch(pair)
|
||||
index += 2
|
||||
next
|
||||
end
|
||||
|
||||
character = characters[index].tr('ャュョ', 'ヤユヨ')
|
||||
sounds << (character == 'ㇷ゚' ? 'p' : SOUNDS[character])
|
||||
index += 1
|
||||
end
|
||||
|
||||
sounds
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
Rails.application.routes.draw do
|
||||
resources :locales, only: [:index]
|
||||
|
||||
resources :tag_groups, only: [:index, :show, :create, :update]
|
||||
|
||||
resources :nico_tags, path: 'tags/nico', only: [:index, :show, :update]
|
||||
|
||||
ファイル差分が大きすぎるため省略します
差分を読み込み
@@ -8,6 +8,11 @@
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# 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 = [
|
||||
{ priority: 10,
|
||||
source_pattern: '\Ahttps?://youtu\.be/([^/?#]+)(?:[?#].*)?\z',
|
||||
|
||||
@@ -24,17 +24,7 @@ FactoryBot.define do
|
||||
|
||||
evaluator.primary_tag_name.update!(tag:, primary_flg: true)
|
||||
|
||||
Locale.where(deprecated_at: nil).order(:language_code, :created_at, :script_code).to_a
|
||||
.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
|
||||
TagNameCompletion.for_tag!(tag)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,9 +6,8 @@ RSpec.describe Language, type: :model do
|
||||
expect(described_class.where(deprecated_at: nil).pluck(:code)).to eq(['ja'])
|
||||
end
|
||||
|
||||
it 'accepts English when multilingual support is enabled' do
|
||||
pending '日本語以外の Language 有効化は別課題で実装する'
|
||||
expect { described_class.create!(code: 'en', name: 'English') }
|
||||
it 'accepts Ainu when multilingual support is enabled' do
|
||||
expect { described_class.create!(code: 'ain', name: 'ain') }
|
||||
.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale activation', type: :model do
|
||||
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
|
||||
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
|
||||
let!(:first) { create(:tag, primary_name: 'activation_first') }
|
||||
let!(:second) { create(:tag, primary_name: 'activation_second') }
|
||||
|
||||
def build_locale deprecated_at: nil
|
||||
Locale.new(code: 'ain-Kana', language_code: language.code,
|
||||
script_code: script.code, region_code: 392, variants: '',
|
||||
name: 'ain-Kana', deprecated_at:)
|
||||
end
|
||||
|
||||
def generated_names
|
||||
TagName.where(language_code: 'ain', primary_flg: true)
|
||||
end
|
||||
|
||||
def fail_second_generation!
|
||||
allow(TagName).to receive(:generate_name).and_call_original
|
||||
allow(TagName).to receive(:generate_name)
|
||||
.with(kind_of(Locale), second, second.tag_name('ja'))
|
||||
.and_raise('backfill failed')
|
||||
end
|
||||
|
||||
it 'rolls back a newly created locale and the preceding generated names' do
|
||||
locale = build_locale
|
||||
fail_second_generation!
|
||||
|
||||
expect { locale.save! }.to raise_error('backfill failed')
|
||||
|
||||
expect(Locale.exists?(code: 'ain-Kana')).to be(false)
|
||||
expect(generated_names).to be_empty
|
||||
expect(first.reload.name('ja')).to eq('activation_first')
|
||||
expect(second.reload.name('ja')).to eq('activation_second')
|
||||
end
|
||||
|
||||
it 'does not backfill while the locale is inactive' do
|
||||
build_locale(deprecated_at: Time.current).save!
|
||||
|
||||
expect(generated_names).to be_empty
|
||||
end
|
||||
|
||||
it 'backfills missing primary names when the locale becomes active' do
|
||||
locale = build_locale(deprecated_at: Time.current)
|
||||
locale.save!
|
||||
|
||||
locale.update!(deprecated_at: nil)
|
||||
|
||||
expect(generated_names.pluck(:tag_id)).to contain_exactly(first.id, second.id)
|
||||
expect(generated_names).to all(have_attributes(
|
||||
script_code: 'Kana', primary_flg: true, auto_generated: true))
|
||||
end
|
||||
|
||||
it 'rolls back activation and all generated names when backfill fails' do
|
||||
locale = build_locale(deprecated_at: Time.current)
|
||||
locale.save!
|
||||
previous_deprecated_at = locale.reload.deprecated_at
|
||||
fail_second_generation!
|
||||
|
||||
expect { locale.update!(deprecated_at: nil) }.to raise_error('backfill failed')
|
||||
|
||||
expect(locale.reload.deprecated_at).to eq(previous_deprecated_at)
|
||||
expect(generated_names).to be_empty
|
||||
end
|
||||
|
||||
it 'preserves a human primary through deactivation and repeated activation' do
|
||||
locale = build_locale
|
||||
locale.save!
|
||||
primary = generated_names.find_by!(tag: first)
|
||||
primary.update!(name: 'human_confirmed', auto_generated: false)
|
||||
snapshot = generated_names.order(:id).map(&:attributes)
|
||||
|
||||
2.times do
|
||||
locale.update!(deprecated_at: Time.current)
|
||||
locale.update!(deprecated_at: nil)
|
||||
end
|
||||
|
||||
expect(generated_names.order(:id).map(&:attributes)).to eq(snapshot)
|
||||
expect(primary.reload).to have_attributes(name: 'human_confirmed', auto_generated: false)
|
||||
end
|
||||
end
|
||||
@@ -1,13 +1,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Locale, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
def create_english_locale!
|
||||
prepare_english_reference!
|
||||
described_class.create!(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: '', name: 'English')
|
||||
def create_ainu_locale!
|
||||
prepare_ainu_reference!
|
||||
described_class.create!(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '', name: 'Ainu')
|
||||
end
|
||||
|
||||
it 'keeps the active Nipponese locale consistent with its identity tuple' do
|
||||
@@ -27,7 +27,6 @@ RSpec.describe Locale, type: :model do
|
||||
end
|
||||
|
||||
it 'generates a primary name for every existing tag using the name generator' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
first = create(:tag, primary_name: 'first_existing_tag')
|
||||
second = create(:tag, primary_name: 'second_existing_tag')
|
||||
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'))
|
||||
.and_return('name_from_generator')
|
||||
|
||||
locale = create_english_locale!
|
||||
locale = create_ainu_locale!
|
||||
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(locale, first, primary_tag_name_for(first, 'ja'))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.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,
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, auto_generated: true)
|
||||
expect(second.tag_names.find_by!(language_code: 'en', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
expect(second.tag_names.find_by!(language_code: 'ain', primary_flg: true))
|
||||
.to have_attributes(tag_id: second.id, language_code: 'ain',
|
||||
script_code: 'Kana', primary_flg: 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)
|
||||
end
|
||||
|
||||
it 'does not duplicate an existing primary name in the new language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
tag = create(:tag, primary_name: 'already_named')
|
||||
existing = create(:tag_name, name: 'existing_english', tag:,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
existing = create(:tag_name, name: 'existing_ainu', tag:,
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
it 'creates a primary name when the language has only an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
tag = create(:tag, primary_name: 'alias_only_tag')
|
||||
alias_name = create(:tag_name, :alias, name: 'alias_en',
|
||||
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(
|
||||
tag_id: tag.id,
|
||||
language_code: 'en', script_code: 'Latn', primary_flg: false)
|
||||
generated = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(generated).to have_attributes(tag_id: tag.id, language_code: 'en',
|
||||
script_code: 'Latn', primary_flg: true,
|
||||
language_code: 'ain', script_code: 'Kana', primary_flg: false)
|
||||
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: 'ain',
|
||||
script_code: 'Kana', primary_flg: 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
|
||||
|
||||
it 'rolls back the locale and all generated names when backfill fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
first = create(:tag, primary_name: 'atomic_first')
|
||||
second = create(:tag, primary_name: 'atomic_second')
|
||||
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'))
|
||||
.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(TagName.where(language_code: 'en', tag_id: [first.id, second.id])).to be_empty
|
||||
expect(described_class.exists?(code: 'ain-Kana')).to be(false)
|
||||
expect(TagName.where(language_code: 'ain', tag_id: [first.id, second.id])).to be_empty
|
||||
end
|
||||
|
||||
it 'accepts a stable locale code outside the historical enum values' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
it 'rejects a private locale code outside the supported locale set' do
|
||||
prepare_ainu_reference!
|
||||
|
||||
locale = described_class.create!(
|
||||
code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
|
||||
expect(locale.reload).to have_attributes(
|
||||
code: 'en-x-rspec', language_code: 'en', script_code: 'Latn',
|
||||
region_code: 826, variants: 'x-rspec')
|
||||
expect {
|
||||
described_class.create!(
|
||||
code: 'ain-Kana-x-rspec', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: 'x-rspec', name: 'Ainu')
|
||||
}.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it 'registers the future British English locale tuple' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'registers the supported Ainu locale tuple' do
|
||||
|
||||
expect(locale).to have_attributes(code: 'en', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
expect(locale).to have_attributes(code: 'ain-Kana', language_code: 'ain',
|
||||
script_code: 'Kana', region_code: 392,
|
||||
variants: '')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Script, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'selects locales by script code, not language code' do
|
||||
expect(described_class.find('Jpan').locales).to contain_exactly(Locale.nipponese)
|
||||
@@ -25,11 +25,10 @@ RSpec.describe Script, type: :model do
|
||||
}
|
||||
end
|
||||
|
||||
it 'selects Latin locales when multilingual support is enabled' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'selects Kana locales when multilingual support is enabled' do
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -95,38 +95,36 @@ RSpec.describe TagNameSanitisationRule, type: :model do
|
||||
end
|
||||
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag_name, name: 'foobar')
|
||||
english = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english.update_columns(name: 'foo_bar')
|
||||
ainu = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu.update_columns(name: 'foo_bar')
|
||||
|
||||
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')
|
||||
end
|
||||
|
||||
it 'renames without merging tags or deleting either language identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'foobar')
|
||||
english_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
english_name.update_columns(name: 'foo_bar')
|
||||
ainu_name = create(:tag_name, name: 'temporary',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
ainu_name.update_columns(name: 'foo_bar')
|
||||
|
||||
described_class.apply!
|
||||
|
||||
expect(english_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'en', tag_id: english.id)
|
||||
expect(ainu_name.reload).to have_attributes(
|
||||
name: 'foobar', language_code: 'ain', tag_id: ainu.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
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
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagName, type: :model do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'allows the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
create(:tag_name, name: 'shared_name')
|
||||
english = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu = build(:tag_name, name: 'shared_name',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(english).to be_valid
|
||||
expect { english.save! }.to change(described_class, :count).by(1)
|
||||
expect(ainu).to be_valid
|
||||
expect { ainu.save! }.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
it 'rejects a duplicate name within the same language' do
|
||||
@@ -67,11 +66,10 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
it 'completes an ordinary tag factory under future active languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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))
|
||||
end
|
||||
|
||||
@@ -114,46 +112,44 @@ RSpec.describe TagName, type: :model do
|
||||
end
|
||||
|
||||
it 'resolves the primary name of the alias language on the same tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語正本')
|
||||
english_primary = primary_tag_name_for(tag, 'en')
|
||||
english_primary.update!(name: 'english_primary', auto_generated: false)
|
||||
english_alias = create(:tag_name, :alias, tag:, name: 'english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_primary = primary_tag_name_for(tag, 'ain')
|
||||
ainu_primary.update!(name: 'ainu_primary', auto_generated: false)
|
||||
ainu_alias = create(:tag_name, :alias, tag:, name: 'ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
|
||||
primary = described_class.find_by!(tag_id: english_alias.tag_id,
|
||||
language_code: english_alias.language_code,
|
||||
primary = described_class.find_by!(tag_id: ainu_alias.tag_id,
|
||||
language_code: ainu_alias.language_code,
|
||||
primary_flg: true)
|
||||
|
||||
expect(primary).to eq(english_primary)
|
||||
expect(described_class.canonicalise(locale, [english_alias.name]))
|
||||
.to eq([english_primary.name])
|
||||
expect(primary).to eq(ainu_primary)
|
||||
expect(described_class.canonicalise(locale, [ainu_alias.name]))
|
||||
.to eq([ainu_primary.name])
|
||||
expect(tag.reload.tag_name('ja')&.id).to eq(representative_id)
|
||||
end
|
||||
|
||||
it 'resolves only aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.canonicalise(locale, ['shared_alias']))
|
||||
.to eq(['english_canonical'])
|
||||
.to eq(['ainu_canonical'])
|
||||
expect(described_class.canonicalise(Locale.nipponese, ['shared_alias']))
|
||||
.to eq(['nipponese_canonical'])
|
||||
end
|
||||
|
||||
@@ -8,7 +8,7 @@ RSpec.describe Tag, type: :model do
|
||||
it 'checks the requested language and treats an empty description as present' do
|
||||
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: 'en')).to be(false)
|
||||
expect(tag.has_description?(language_code: 'ain')).to be(false)
|
||||
end
|
||||
|
||||
it 'returns false without a description' do
|
||||
@@ -23,16 +23,16 @@ RSpec.describe Tag, type: :model do
|
||||
subscriber = ->(*args) { queries << args.last.fetch(:sql) }
|
||||
ActiveSupport::Notifications.subscribed(subscriber, 'sql.active_record') do
|
||||
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
|
||||
expect(queries).to be_empty
|
||||
end
|
||||
|
||||
it 'keeps language selection generic without enabling another Language record' do
|
||||
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!
|
||||
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)
|
||||
end
|
||||
end
|
||||
@@ -61,33 +61,32 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
describe '.normalise_tags!' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'canonicalises aliases in the supplied language only' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_reference!
|
||||
prepare_ainu_reference!
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name)
|
||||
primary_tag_name_for(english, 'ja').update!(
|
||||
name: 'english_in_nipponese', auto_generated: false)
|
||||
english_locale = locale
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name)
|
||||
primary_tag_name_for(ainu, 'ja').update!(
|
||||
name: 'ainu_in_nipponese', auto_generated: false)
|
||||
ainu_locale = locale
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
expect(described_class.normalise_tags!(
|
||||
english_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([english])
|
||||
ainu_locale, ['shared_alias'],
|
||||
with_tagme: false, with_no_deerjikist: false)).to eq([ainu])
|
||||
expect(described_class.normalise_tags!(
|
||||
Locale.nipponese, ['shared_alias'],
|
||||
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
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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(
|
||||
language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tag, 'ain')).to have_attributes(
|
||||
language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tag.id)
|
||||
expect(tag.category).to eq('character')
|
||||
end
|
||||
|
||||
it 'uses the English primary as the source for a generated Nipponese name' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'uses a Japanese fallback for an Ainu-origin tag without reverse transcription' do
|
||||
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!(
|
||||
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(
|
||||
name: 'english_origin', auto_generated: false))
|
||||
name: 'ainu_origin', auto_generated: false))
|
||||
expect(TagName.where(tag:, language_code: 'ja', primary_flg: true))
|
||||
.to contain_exactly(have_attributes(
|
||||
name: 'generated_nipponese', auto_generated: true))
|
||||
source_tag_name = TagName.find_by!(tag:, language_code: 'en', primary_flg: true)
|
||||
name: "Tag_##{ tag.id }", auto_generated: true))
|
||||
expect(TagName).to have_received(:generate_name)
|
||||
.with(Locale.nipponese, tag, source_tag_name)
|
||||
.with(Locale.nipponese, tag, nil)
|
||||
end
|
||||
|
||||
it 'gives a new tag one primary name for every active language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = described_class.find_or_create_by_tag_name!(
|
||||
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: 'en', primary_flg: true).count).to eq(1)
|
||||
expect(TagName.where(tag:, language_code: 'ain', primary_flg: true).count).to eq(1)
|
||||
end
|
||||
|
||||
it 'creates one primary per language when multiple locales use English' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'creates one primary per language when multiple locales use Ainu' do
|
||||
locale
|
||||
described_class.find_or_create_by_tag_name!(
|
||||
locale, 'before_second_english_locale', category: :general)
|
||||
Locale.create!(code: 'en-x-rspec', language_code: 'en',
|
||||
script_code: 'Latn', region_code: 826,
|
||||
variants: 'x-rspec', name: 'English')
|
||||
locale, 'before_second_ainu_locale', category: :general)
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
it 'rolls back a new tag and requested name if name generation fails' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
original_tag_count = described_class.count
|
||||
allow(TagName).to receive(:generate_name).and_raise('generation failed')
|
||||
|
||||
expect {
|
||||
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')
|
||||
|
||||
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)
|
||||
.where(tag_names: { name: 'atomic_english_name' })).to be_empty
|
||||
.where(tag_names: { name: 'atomic_ainu_name' })).to be_empty
|
||||
end
|
||||
|
||||
|
||||
|
||||
it 'creates separate tag identities for the same name in different languages' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = described_class.find_or_create_by_tag_name!(
|
||||
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)
|
||||
|
||||
expect(english).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(english, 'en')).to have_attributes(
|
||||
name: 'same_name', tag_id: english.id, language_code: 'en', primary_flg: true)
|
||||
expect(ainu).not_to eq(nipponese)
|
||||
expect(primary_tag_name_for(ainu, 'ain')).to have_attributes(
|
||||
name: 'same_name', tag_id: ainu.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary_tag_name_for(nipponese, 'ja')).to have_attributes(
|
||||
name: 'same_name', tag_id: nipponese.id, language_code: 'ja', primary_flg: true)
|
||||
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!(
|
||||
Locale.nipponese, 'same_name', category: :general)).to eq(nipponese)
|
||||
end
|
||||
|
||||
it 'preserves the V1 representative when looking up another primary language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tag = create(:tag, primary_name: '日本語代表名')
|
||||
representative_id = tag.tag_name('ja')&.id
|
||||
english = primary_tag_name_for(tag, 'en')
|
||||
english.update!(name: 'english_primary', auto_generated: false)
|
||||
ainu = primary_tag_name_for(tag, 'ain')
|
||||
ainu.update!(name: 'ainu_primary', auto_generated: false)
|
||||
|
||||
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(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
|
||||
|
||||
it 'normalises names using the supplied locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
tags = described_class.normalise_tags!(
|
||||
locale, ['character:normalised_english'],
|
||||
locale, ['character:normalised_ainu'],
|
||||
with_tagme: false, with_no_deerjikist: false)
|
||||
|
||||
expect(tags.length).to eq(1)
|
||||
expect(primary_tag_name_for(tags.first, 'en')).to have_attributes(
|
||||
name: 'normalised_english', language_code: 'en', script_code: 'Latn',
|
||||
expect(primary_tag_name_for(tags.first, 'ain')).to have_attributes(
|
||||
name: 'normalised_ainu', language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, tag_id: tags.first.id)
|
||||
end
|
||||
end
|
||||
@@ -444,26 +434,25 @@ RSpec.describe Tag, type: :model do
|
||||
end
|
||||
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
english_primary = primary_tag_name_for(target_tag, 'en')
|
||||
source_primary = primary_tag_name_for(source_tag, 'en')
|
||||
ainu_primary = primary_tag_name_for(target_tag, 'ain')
|
||||
source_primary = primary_tag_name_for(source_tag, 'ain')
|
||||
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))
|
||||
|
||||
described_class.merge_tags!(target_tag, [source_tag])
|
||||
|
||||
expect(primary_tag_name_for(target_tag.reload, 'en')).to eq(english_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'en', primary_flg: true))
|
||||
.to contain_exactly(english_primary)
|
||||
expect(primary_tag_name_for(target_tag.reload, 'ain')).to eq(ainu_primary)
|
||||
expect(TagName.where(tag: target_tag, language_code: 'ain', primary_flg: true))
|
||||
.to contain_exactly(ainu_primary)
|
||||
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))
|
||||
.to contain_exactly(primary_tag_name_for(target_tag, 'ja'))
|
||||
expect(source_tag_name.reload).to have_attributes(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'API locale resolution', type: :request do
|
||||
let!(:tag) { create(:tag, primary_name: '日本語正本') }
|
||||
|
||||
it 'defaults an omitted locale to Japanese' do
|
||||
get "/tags/#{ tag.id }"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
it 'accepts an explicitly requested active Japanese locale' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(json.fetch('name')).to eq('日本語正本')
|
||||
end
|
||||
|
||||
['', 'en', 'ain', 'Jpan', 'JA', 'unknown', ['ja']].each do |code|
|
||||
it "rejects invalid locale #{ code.inspect } instead of defaulting to Japanese" do
|
||||
get "/tags/#{ tag.id }", params: { locale: code }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
expect(response.body).not_to include('日本語正本')
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects a supported locale that has not been registered' do
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ain-Kana' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'rejects an inactive locale' do
|
||||
Locale.nipponese.update!(deprecated_at: Time.current)
|
||||
|
||||
get "/tags/#{ tag.id }", params: { locale: 'ja' }
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
|
||||
it 'does not write posts or tag names under an invalid locale' do
|
||||
sign_in_as(create_member_user!)
|
||||
|
||||
expect do
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'invalid locale', url: 'https://example.com/invalid-locale',
|
||||
tags: 'unwritten_tag' }
|
||||
end.to change(Post, :count).by(0).and change(TagName, :count).by(0)
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:member) { create(:user, :member) }
|
||||
|
||||
@@ -54,13 +54,12 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
['en', nil].each do |requested_locale|
|
||||
['ain-Kana', nil].each do |requested_locale|
|
||||
context "with locale #{ requested_locale.inspect }" do
|
||||
let(:locale_params) { requested_locale ? { locale: requested_locale } : { } }
|
||||
let(:expected_locale) { requested_locale ? locale : Locale.nipponese }
|
||||
|
||||
it 'creates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/posts', params: locale_params.merge(
|
||||
title: 'Locale post', url: 'https://example.com/locale-post',
|
||||
@@ -72,7 +71,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates post tags in the requested language or Nipponese fallback' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
record = create(:post)
|
||||
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
|
||||
|
||||
it 'passes the resolved locale to bulk creation' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
# Worker propagation is exercised in post_bulk_creator_spec.
|
||||
expect_any_instance_of(PostBulkCreator).to receive(:run)
|
||||
@@ -102,7 +99,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
post '/materials', params: locale_params.merge(
|
||||
tag: 'locale_material', url: 'https://example.com/material')
|
||||
@@ -113,7 +109,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'updates materials with tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag, category: :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
|
||||
|
||||
it 'links Nico external tags to internal tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
@@ -141,7 +135,6 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
it 'creates parent tags in the resolved locale' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する' if requested_locale
|
||||
locale if requested_locale
|
||||
tag = create(:tag)
|
||||
|
||||
@@ -157,10 +150,10 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
|
||||
context 'with existing names in both languages' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'en', script_code: 'Latn')
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'shared_name', language_code: 'ain', script_code: 'Kana')
|
||||
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
|
||||
create(:tag, primary_name: 'temporary_nipponese', category: :general).tap do |tag|
|
||||
# Isolate lookup from the separately tested uniqueness validation.
|
||||
@@ -168,173 +161,163 @@ RSpec.describe 'Locale propagation on write paths', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
it 'plans and creates the post using the English identity and category' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'plans and creates the post using the Ainu identity and category' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
post '/posts', params: {
|
||||
locale: 'en', title: 'English identity',
|
||||
url: 'https://example.com/english-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
locale: 'ain-Kana', title: 'Ainu identity',
|
||||
url: 'https://example.com/ainu-identity', tags: 'shared_name', parent_post_ids: '' }
|
||||
|
||||
expect(response).to have_http_status(:created), response.body
|
||||
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(english_tag.reload.category).to eq('material')
|
||||
expect(ainu_tag.reload.category).to eq('material')
|
||||
expect(nipponese_tag.reload.category).to eq('general')
|
||||
end
|
||||
|
||||
it 'updates the post using the English identity' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'updates the post using the Ainu identity' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
record = create(:post)
|
||||
PostVersionRecorder.record!(post: record, event_type: :create, created_by_user: member)
|
||||
|
||||
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 }
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
it 'resolves the English material tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu material tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
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(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
|
||||
end
|
||||
|
||||
it 'resolves the English parent tag' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu parent tag' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
child = create(:tag)
|
||||
|
||||
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,
|
||||
aliases: '', parent_tags: 'shared_name' }
|
||||
|
||||
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
|
||||
|
||||
it 'resolves the English internal tag for a Nico link' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'resolves the Ainu internal tag for a Nico link' do
|
||||
locale
|
||||
english_tag
|
||||
ainu_tag
|
||||
nipponese_tag
|
||||
external = create(:external_tag)
|
||||
NicoTagVersionRecorder.record!(
|
||||
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(external.reload.linked_tags).to contain_exactly(english_tag)
|
||||
expect(external.reload.linked_tags).to contain_exactly(ainu_tag)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when renaming or updating aliases in English' do
|
||||
let(:english_name) do
|
||||
create(:tag_name, name: 'english_original', language_code: 'en', script_code: 'Latn')
|
||||
context 'when renaming or updating aliases in Ainu' do
|
||||
let(:ainu_name) do
|
||||
create(:tag_name, name: 'ainu_original', language_code: 'ain', script_code: 'Kana')
|
||||
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
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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)
|
||||
|
||||
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(generated_name.reload).to have_attributes(
|
||||
name: 'human_english_name', auto_generated: false)
|
||||
name: 'human_ainu_name', auto_generated: false)
|
||||
end
|
||||
|
||||
it 'does not steal another tag primary for an alias' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
other_tag = create(:tag, primary_name: 'other_nipponese')
|
||||
other_primary = primary_tag_name_for(other_tag, 'en')
|
||||
other_primary.update!(name: 'other_english_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_english_alias',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
primary_name_before = primary_tag_name_for(tag, 'en').name
|
||||
other_primary = primary_tag_name_for(other_tag, 'ain')
|
||||
other_primary.update!(name: 'other_ainu_primary', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, name: 'existing_ainu_alias',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
primary_name_before = primary_tag_name_for(tag, 'ain').name
|
||||
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)
|
||||
category_before = tag.category
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: 'renamed_before_alias_collision',
|
||||
locale: 'ain-Kana', name: 'renamed_before_alias_collision',
|
||||
category: 'character', deprecated: false,
|
||||
aliases: 'other_english_primary', parent_tags: '' }
|
||||
aliases: 'other_ainu_primary', parent_tags: '' }
|
||||
|
||||
aggregate_failures do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(other_primary.reload).to have_attributes(
|
||||
tag_id: other_tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(TagName.where(tag: other_tag, 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: 'ain', primary_flg: true))
|
||||
.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)
|
||||
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)
|
||||
expect(aliases_after).to eq(aliases_before)
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows a rename to a name already used in Nipponese' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
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(primary_tag_name_for(tag.reload, 'en')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'en', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(tag.reload, 'ain')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ain', tag_id: tag.id)
|
||||
expect(primary_tag_name_for(nipponese.reload, 'ja')).to have_attributes(
|
||||
name: 'rename_target', language_code: 'ja', tag_id: nipponese.id)
|
||||
end
|
||||
|
||||
it 'creates aliases in the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
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(TagName.find_by!(language_code: 'en', name: 'english_alias'))
|
||||
expect(TagName.find_by!(language_code: 'ain', name: 'ainu_alias'))
|
||||
.to have_attributes(
|
||||
script_code: 'Latn',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'en')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'en', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'english_original', script_code: 'Latn')
|
||||
script_code: 'Kana',
|
||||
tag_id: tag.id, primary_flg: false, language_code: 'ain')
|
||||
primary = TagName.find_by!(tag_id: tag.id, language_code: 'ain', primary_flg: true)
|
||||
expect(primary).to have_attributes(name: 'ainu_original', script_code: 'Kana')
|
||||
end
|
||||
|
||||
it 'does not take an alias from another language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_owner')
|
||||
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')
|
||||
|
||||
put "/tags/#{ tag.id }", params: {
|
||||
locale: 'en', name: english_name.name,
|
||||
locale: 'ain-Kana', name: ainu_name.name,
|
||||
category: tag.category, deprecated: false,
|
||||
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(
|
||||
tag_id: nipponese.id,
|
||||
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(
|
||||
tag_id: tag.id, script_code: 'Latn',
|
||||
primary_flg: false, language_code: 'en')
|
||||
tag_id: tag.id, script_code: 'Kana',
|
||||
primary_flg: false, language_code: 'ain')
|
||||
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
|
||||
|
||||
@@ -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
|
||||
["/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)
|
||||
representation = json.is_a?(Array) ? json.sole : json
|
||||
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
|
||||
version = group.versions.sole
|
||||
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(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(json.fetch('body').scan('ainu_member').length).to eq(2)
|
||||
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(json.fetch('diff').map { _1.fetch('type') }.uniq).to eq(['context'])
|
||||
end
|
||||
@@ -52,23 +52,24 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
['タググループ:', 'tag_group:', 'TAG_GROUP:'].each do |prefix|
|
||||
it "round trips #{ prefix } through create, lookup, search and update" do
|
||||
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
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
expect(json.fetch('body')).to include('タググループ:locale group')
|
||||
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(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)
|
||||
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])
|
||||
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)
|
||||
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')
|
||||
end
|
||||
end
|
||||
@@ -81,7 +82,7 @@ RSpec.describe 'Tag group locale contracts', type: :request do
|
||||
end.to raise_error(TagGroupMarkdownParser::InvalidDocument)
|
||||
before_state = group_state
|
||||
put "/wiki/#{ group.id }", params: {
|
||||
locale: 'ain', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
locale: 'ain-Kana', title: 'タググループ:partial rename', body: '- japanese_member' },
|
||||
headers: headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
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
|
||||
post '/tag_groups', params: {
|
||||
locale: 'ain', name: 'native', description: nil, members:, placements: },
|
||||
locale: 'ain-Kana', name: 'native', description: nil, members:, placements: },
|
||||
headers:, as: :json
|
||||
expect(response).to have_http_status(:created)
|
||||
id = json.fetch('id')
|
||||
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)
|
||||
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
|
||||
expect(response).to have_http_status(:ok)
|
||||
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)
|
||||
expect(representation.fetch('members').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.body).not_to include('historical_japanese', 'current_ainu')
|
||||
end
|
||||
|
||||
@@ -20,12 +20,12 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
|
||||
['/wiki', '/wiki/search'].each do |path|
|
||||
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(json.map { _1.fetch('id') }).to eq([ainu.id])
|
||||
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(json).to eq([])
|
||||
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
|
||||
['/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)
|
||||
end
|
||||
['/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(json.fetch('body')).to eq('Ainu body')
|
||||
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)
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -57,14 +57,14 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
revision = current_description_version(japanese)
|
||||
["/wiki/#{ japanese.id }", "/wiki/#{ japanese.id }/exists",
|
||||
"/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.body).not_to include('Japanese body')
|
||||
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(json).to eq([])
|
||||
get '/wiki/changes', params: { locale: 'ain' }
|
||||
get '/wiki/changes', params: { locale: 'ain-Kana' }
|
||||
expect(json.map { _1.fetch('revision_id') })
|
||||
.to eq([current_description_version(ainu).id])
|
||||
end
|
||||
@@ -73,7 +73,7 @@ RSpec.describe 'Wiki and native API locale contracts', type: :request do
|
||||
snapshot = [japanese.attributes, ainu.attributes, ainu_name.attributes]
|
||||
expect do
|
||||
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)
|
||||
expect(response).to have_http_status(:not_found)
|
||||
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
|
||||
expect do
|
||||
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
|
||||
else
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain', body: 'created' },
|
||||
put "/tags/#{ other.id }/description", params: { locale: 'ain-Kana', body: 'created' },
|
||||
headers: headers
|
||||
end
|
||||
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
|
||||
snapshot = japanese.attributes
|
||||
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
|
||||
expect(response).to have_http_status(:ok)
|
||||
second = current_description_version(ainu)
|
||||
expect(second).to have_attributes(language_code: 'ain', version_no: 2)
|
||||
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)
|
||||
|
||||
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
|
||||
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(japanese.reload.attributes).to eq(snapshot)
|
||||
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]
|
||||
expect do
|
||||
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
|
||||
end.to change(TagDescriptionVersion, :count).by(0).and change(TagVersion, :count).by(0)
|
||||
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)
|
||||
TagDescriptionOperation.update!(
|
||||
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(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(json.fetch('newer_revision_id')).to eq(current_description_version(ainu).id)
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
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
|
||||
locale = Locale.nipponese
|
||||
@@ -67,8 +67,7 @@ RSpec.describe PostBulkCreator do
|
||||
expect(results[3].dig(:post, :id)).to eq(4)
|
||||
end
|
||||
|
||||
it 'passes a future English locale to preflight and creation in every worker' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
it 'passes a future Ainu locale to preflight and creation in every worker' do
|
||||
locale
|
||||
actor = instance_double(User, id: 123)
|
||||
allow(User).to receive(:find).with(123).and_return(actor)
|
||||
|
||||
@@ -11,23 +11,22 @@ RSpec.describe PostCreatePlan do
|
||||
end
|
||||
|
||||
context 'with an explicit locale' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
it 'resolves the matching language identity and its parents' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
english_name = create(:tag_name, name: 'shared_name',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
||||
ainu_name = create(:tag_name, name: 'shared_name',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
|
||||
nipponese = create(:tag, primary_name: 'temporary_nipponese', category: :general)
|
||||
# The DB permits this identity; validation has its own contract spec.
|
||||
primary_tag_name_for(nipponese, 'ja').update_columns(name: 'shared_name')
|
||||
english_parent_name = create(:tag_name, name: 'english_parent',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english_parent = create(:tag, primary_tag_name: english_parent_name,
|
||||
ainu_parent_name = create(:tag_name, name: 'ainu_parent',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu_parent = create(:tag, primary_tag_name: ainu_parent_name,
|
||||
category: :material)
|
||||
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)
|
||||
|
||||
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(
|
||||
[{ name: 'shared_name', category: :character }])
|
||||
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))
|
||||
.not_to include(primary_tag_name_for(nipponese_parent, 'ja').name)
|
||||
end
|
||||
|
||||
it 'resolves aliases within the requested language' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
nipponese = create(:tag, primary_name: 'nipponese_canonical')
|
||||
english_name = create(:tag_name, name: 'english_canonical',
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
english = create(:tag, primary_tag_name: english_name, category: :character)
|
||||
ainu_name = create(:tag_name, name: 'ainu_canonical',
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
ainu = create(:tag, primary_tag_name: ainu_name, category: :character)
|
||||
nipponese_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: nipponese)
|
||||
english_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: english,
|
||||
language_code: 'en', script_code: 'Latn')
|
||||
ainu_alias = create(:tag_name, :alias, name: 'shared_alias',
|
||||
tag: ainu,
|
||||
language_code: 'ain', script_code: 'Kana')
|
||||
|
||||
expect(nipponese_alias.reload).to have_attributes(
|
||||
tag_id: nipponese.id,
|
||||
primary_flg: false, language_code: 'ja')
|
||||
expect(english_alias.reload).to have_attributes(
|
||||
tag_id: english.id,
|
||||
primary_flg: false, language_code: 'en')
|
||||
expect(ainu_alias.reload).to have_attributes(
|
||||
tag_id: ainu.id,
|
||||
primary_flg: false, language_code: 'ain')
|
||||
|
||||
plan = described_class.new(attributes: { tags: 'shared_alias' }).build!(locale)
|
||||
|
||||
expect(plan[:direct_tag_specs]).to eq(
|
||||
[{ name: english_name.name, category: :character }])
|
||||
[{ name: ainu_name.name, category: :character }])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -77,11 +77,10 @@ RSpec.describe PostCreator do
|
||||
end
|
||||
|
||||
context 'with an explicit locale' do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
[false, true].each do |planned|
|
||||
it "passes locale to tag creation with planned attributes: #{ planned }" do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
locale
|
||||
attributes = {
|
||||
title: 'Locale propagation',
|
||||
@@ -97,8 +96,8 @@ RSpec.describe PostCreator do
|
||||
|
||||
expect(Tag).to have_received(:find_or_create_by_tag_name!)
|
||||
.with(locale, 'locale_character', category: :character).at_least(:once)
|
||||
tag_name = TagName.find_by!(language_code: 'en', name: 'locale_character')
|
||||
expect(tag_name).to have_attributes(script_code: 'Latn', primary_flg: true)
|
||||
tag_name = TagName.find_by!(language_code: 'ain', name: 'locale_character')
|
||||
expect(tag_name).to have_attributes(script_code: 'Kana', primary_flg: true)
|
||||
expect(post.tags).to include(tag_name.tag)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagNameCompletion do
|
||||
let!(:language) { Language.create!(code: 'ain', name: 'ain') }
|
||||
let!(:script) { Script.create!(code: 'Kana', name: 'Kana') }
|
||||
let(:locale) do
|
||||
Locale.create!(code: 'ain-Kana', language_code: 'ain', script_code: 'Kana',
|
||||
region_code: 392, variants: '', name: 'ain-Kana')
|
||||
end
|
||||
|
||||
it 'generates a phonetic primary with the required identity and provenance' do
|
||||
tag = create(:tag, primary_name: '漢字あカ')
|
||||
|
||||
locale
|
||||
|
||||
expect(tag.reload.tag_name('ain')).to have_attributes(
|
||||
name: 'カンチ_ア_カ', language_code: 'ain', script_code: 'Kana',
|
||||
primary_flg: true, auto_generated: true, tag_id: tag.id)
|
||||
end
|
||||
|
||||
it 'uses the exact fallback when the input cannot be converted' do
|
||||
tag = create(:tag, primary_name: '😀')
|
||||
|
||||
locale
|
||||
|
||||
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
|
||||
end
|
||||
|
||||
it 'uses the exact fallback when a generated name collides with a human name' do
|
||||
other = create(:tag, primary_name: 'other')
|
||||
create(:tag_name, tag: other, name: 'AbC', language_code: 'ain', script_code: 'Kana')
|
||||
tag = create(:tag, primary_name: 'AbC')
|
||||
|
||||
locale
|
||||
|
||||
expect(tag.reload.name('ain')).to eq("Tag_##{ tag.id }")
|
||||
expect(other.reload.name('ain')).to eq('AbC')
|
||||
end
|
||||
|
||||
it 'uses the exact fallback for a transcription that fails name validation' do
|
||||
tag = create(:tag, primary_name: 'base')
|
||||
source = TagName.new(name: 'nico:invalid', language_code: 'ja')
|
||||
|
||||
expect(TagName.generate_name(locale, tag, source)).to eq("Tag_##{ tag.id }")
|
||||
end
|
||||
|
||||
it 'preserves human confirmation and is idempotent across repeated completion' do
|
||||
locale
|
||||
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'あいぬ', category: :general)
|
||||
primary = tag.tag_name('ain')
|
||||
primary.update!(name: 'human_confirmed', auto_generated: false)
|
||||
snapshot = tag.tag_names.order(:id).map(&:attributes)
|
||||
|
||||
2.times { described_class.for_tag!(tag) }
|
||||
|
||||
expect(tag.tag_names.order(:id).map(&:attributes)).to eq(snapshot)
|
||||
end
|
||||
|
||||
it 'creates one primary per active language through the production creation path' do
|
||||
locale
|
||||
|
||||
tag = Tag.find_or_create_by_tag_name!(Locale.nipponese, 'アイヌ', category: :general)
|
||||
|
||||
expect(tag.tag_names.where(primary_flg: true).pluck(:language_code, :name))
|
||||
.to contain_exactly(['ja', 'アイヌ'], ['ain', 'アイヌ'])
|
||||
expect(Tag.find_or_create_by_tag_name!(locale, 'アイヌ', category: :general)).to eq(tag)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagNameTranscription do
|
||||
{
|
||||
'AbCdXYZ' => 'AbCdXYZ',
|
||||
'あいぬ' => 'アイヌ',
|
||||
'アイヌ' => 'アイヌ',
|
||||
'日本語' => 'ニチホンコ',
|
||||
'漢字' => 'カンチ',
|
||||
'漢あカ' => 'カン_ア_カ',
|
||||
'漢字あいうカナ' => 'カンチ_アイウ_カナ',
|
||||
'Ab漢あカZ' => 'Abカン_ア_カZ',
|
||||
'りゃリュリョ' => 'リヤ_リユリヨ',
|
||||
'キャット' => 'キヤット',
|
||||
'コーヒー' => 'コヒ',
|
||||
'コウコオケイケエカア' => 'コウコオケイケエカア',
|
||||
'ガギグゲゴダヂヅデド' => 'カキクケコタチチュテト',
|
||||
'バビブベボパピプペポ' => 'パピプペポパピプペポ',
|
||||
'ザジズゼゾ' => 'チャチチュチェチョ',
|
||||
'ジャジュジョ' => 'チヤチユチヨ',
|
||||
'アㇰアㇱアッアンアㇺアㇷ゚' => 'アㇰアㇱアッアンアㇺアㇷ゚',
|
||||
'アㇻイㇼウㇽエㇾオㇿ' => 'アㇻイㇼウㇽエㇾオㇿ',
|
||||
'漢Aあ' => 'カンAア' }.each do |source, expected|
|
||||
it "transcribes #{ source } as #{ expected }" do
|
||||
expect(described_class.ainu(source)).to eq(expected)
|
||||
end
|
||||
end
|
||||
|
||||
['😀', 'ヴ', 'ティ', 'ㇵ', '魑', '𠮷', 'ー', '々'].each do |source|
|
||||
it "rejects unsupported input #{ source }" do
|
||||
expect { described_class.ainu(source) }.to raise_error(described_class::Unconvertible)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe TagVersionRecorder do
|
||||
include_context 'English locale'
|
||||
include_context 'Ainu locale'
|
||||
|
||||
let(:user) { create_member_user! }
|
||||
let!(:tag) { create(:tag, primary_name: '日本語名') }
|
||||
@@ -17,11 +17,11 @@ RSpec.describe TagVersionRecorder do
|
||||
{ 'name' => 'alias_z', 'script_code' => 'Jpan' }] }
|
||||
end
|
||||
|
||||
def prepare_english_names!
|
||||
def prepare_ainu_names!
|
||||
locale
|
||||
primary_tag_name_for(tag, 'en').update!(name: 'English name', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, language_code: 'en', script_code: 'Latn',
|
||||
name: 'English alias')
|
||||
primary_tag_name_for(tag, 'ain').update!(name: 'Ainu name', auto_generated: false)
|
||||
create(:tag_name, :alias, tag:, language_code: 'ain', script_code: 'Kana',
|
||||
name: 'Ainu alias')
|
||||
end
|
||||
|
||||
it 'snapshots the Nipponese primary and sorted aliases in grouped names' do
|
||||
@@ -62,35 +62,32 @@ RSpec.describe TagVersionRecorder do
|
||||
end
|
||||
|
||||
it 'snapshots future languages together without a request locale argument' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
prepare_ainu_names!
|
||||
|
||||
version = described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||
|
||||
expect(version.names).to eq(
|
||||
'ja' => nipponese_snapshot,
|
||||
'en' => { 'name' => 'English name', 'script_code' => 'Latn',
|
||||
'ain' => { 'name' => 'Ainu name', 'script_code' => 'Kana',
|
||||
'auto_generated' => false,
|
||||
'aliases' => [{ 'name' => 'English alias', 'script_code' => 'Latn' }] })
|
||||
'aliases' => [{ 'name' => 'Ainu alias', 'script_code' => 'Kana' }] })
|
||||
end
|
||||
|
||||
it 'creates a version for an English-only change and preserves prior names' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
it 'creates a version for an Ainu-only change and preserves prior names' do
|
||||
prepare_ainu_names!
|
||||
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)
|
||||
|
||||
expect(updated.version_no).to eq(2)
|
||||
expect(original.reload.names.fetch('en')).to include('name' => 'English name')
|
||||
expect(updated.names.fetch('en')).to include('name' => 'Renamed')
|
||||
expect(original.reload.names.fetch('ain')).to include('name' => 'Ainu name')
|
||||
expect(updated.names.fetch('ain')).to include('name' => 'Renamed')
|
||||
expect(updated.names.fetch('ja')).to eq(nipponese_snapshot)
|
||||
end
|
||||
|
||||
it 'does not create a version for an unchanged multilingual snapshot' do
|
||||
pending '日本語以外の Locale 有効化は別課題で実装する'
|
||||
prepare_english_names!
|
||||
prepare_ainu_names!
|
||||
described_class.record!(tag:, event_type: :create, created_by_user: user)
|
||||
|
||||
unchanged = described_class.record!(tag:, event_type: :update, created_by_user: user)
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
RSpec.shared_context 'Ainu locale contract' do
|
||||
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
|
||||
# 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)
|
||||
INSERT IGNORE INTO languages (code, name, created_at)
|
||||
VALUES ('ain', 'Ainu', CURRENT_TIMESTAMP)
|
||||
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).with(code: 'ain').and_return(ainu_locale)
|
||||
allow(Locale).to receive(:find_by).with(code: 'ain-Kana').and_return(ainu_locale)
|
||||
end
|
||||
|
||||
def add_ainu_name tag, name
|
||||
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,
|
||||
created_at: Time.current, updated_at: Time.current }])
|
||||
tag.tag_names.reset
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
RSpec.shared_context 'English locale' do
|
||||
def prepare_english_reference!
|
||||
Language.find_or_create_by!(code: 'en') { _1.name = 'English' }
|
||||
Script.find_or_create_by!(code: 'Latn') { _1.name = 'Latin' }
|
||||
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
|
||||
RSpec.shared_context 'Ainu locale' do
|
||||
def prepare_ainu_reference!
|
||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
||||
end
|
||||
|
||||
let(:locale) do
|
||||
prepare_english_reference!
|
||||
Locale.find_or_create_by!(code: 'en') do |record|
|
||||
record.language_code = 'en'
|
||||
record.script_code = 'Latn'
|
||||
record.region_code = 826
|
||||
prepare_ainu_reference!
|
||||
Locale.find_or_create_by!(code: 'ain-Kana') do |record|
|
||||
record.language_code = 'ain'
|
||||
record.script_code = 'Kana'
|
||||
record.region_code = 392
|
||||
record.variants = ''
|
||||
record.name = 'English'
|
||||
record.name = 'Ainu'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
生成ファイル
+78
-3
@@ -25,12 +25,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"i18next": "^26.4.7",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"path-to-regexp": "^8.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^17.0.20",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"react-router-dom": "^6.30.1",
|
||||
@@ -350,9 +352,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz",
|
||||
"integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz",
|
||||
"integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -4872,6 +4874,15 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -4916,6 +4927,34 @@
|
||||
"integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==",
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "16.13.1",
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@@ -29,12 +29,14 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"humps": "^2.0.1",
|
||||
"i18next": "^26.4.7",
|
||||
"lucide-react": "^0.511.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"path-to-regexp": "^8.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-i18next": "^17.0.20",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-markdown-editor-lite": "^1.3.4",
|
||||
"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 { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { Fragment, useEffect, useLayoutEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { createBrowserRouter,
|
||||
Navigate,
|
||||
Route,
|
||||
RouterProvider,
|
||||
Routes,
|
||||
useLocation } from 'react-router-dom'
|
||||
useLocation,
|
||||
useParams } from 'react-router-dom'
|
||||
|
||||
import DevModeWatermark from '@/components/DevModeWatermark'
|
||||
import RouteBlockerOverlay from '@/components/RouteBlockerOverlay'
|
||||
@@ -13,6 +16,10 @@ import TopNav from '@/components/TopNav'
|
||||
import DialogueProvider from '@/components/dialogues/DialogueProvider'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
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,
|
||||
applyClientAppearance,
|
||||
fetchUserThemeSlots,
|
||||
@@ -36,7 +43,7 @@ import MorePage from '@/pages/MorePage'
|
||||
import GekanatorPage from '@/pages/GekanatorPage'
|
||||
import NicoTagListPage from '@/pages/tags/NicoTagListPage'
|
||||
import NotFound from '@/pages/NotFound'
|
||||
import TOSPage from '@/pages/TOSPage.mdx'
|
||||
import TOSPage from '@/pages/TOSPage'
|
||||
import HelpDetailPage from '@/pages/help/HelpDetailPage'
|
||||
import HelpChangesPage from '@/pages/help/HelpChangesPage'
|
||||
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 { ClientAnimationMode } from '@/lib/settings'
|
||||
import type { AvailableLocale } from '@/lib/locales'
|
||||
import type { User } from '@/types'
|
||||
|
||||
|
||||
@@ -73,41 +81,41 @@ const RouteTransitionWrapper = ({ animationMode, user, setUser }: {
|
||||
|
||||
const routes = (
|
||||
<Routes location={location}>
|
||||
<Route path="/" element={<Navigate to="/posts" replace/>}/>
|
||||
<Route path="/posts" element={<PostListPage/>}/>
|
||||
<Route path="/posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="/posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="/posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="/tags" element={<TagListPage/>}/>
|
||||
<Route path="/tags/:id" element={<TagDetailPage/>}/>
|
||||
<Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
||||
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/nico/tags" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="/tags/changes" element={<TagHistoryPage/>}/>
|
||||
<Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||
<Route path="/materials" element={<MaterialBasePage/>}>
|
||||
<Route path="" element={<LocaleRedirect to="/posts"/>}/>
|
||||
<Route path="posts" element={<PostListPage/>}/>
|
||||
<Route path="posts/new" element={<PostNewPage user={user}/>}/>
|
||||
<Route path="posts/search" element={<PostSearchPage/>}/>
|
||||
<Route path="posts/:id" element={<PostDetailRoute user={user}/>}/>
|
||||
<Route path="posts/changes" element={<PostHistoryPage/>}/>
|
||||
<Route path="tags" element={<TagListPage/>}/>
|
||||
<Route path="tags/:id" element={<TagDetailPage/>}/>
|
||||
<Route path="tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/>
|
||||
<Route path="tags/nico" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="nico/tags" element={<NicoTagListPage user={user}/>}/>
|
||||
<Route path="tags/changes" element={<TagHistoryPage/>}/>
|
||||
<Route path="theatres/:id" element={<TheatreDetailPage user={user}/>}/>
|
||||
<Route path="materials" element={<MaterialBasePage/>}>
|
||||
<Route index element={<MaterialListPage/>}/>
|
||||
<Route path="changes" element={<MaterialHistoryPage/>}/>
|
||||
<Route path="new" element={<MaterialNewPage/>}/>
|
||||
<Route path="suppressions" element={<MaterialSyncSuppressionsPage/>}/>
|
||||
<Route path=":id" element ={<MaterialDetailPage/>}/>
|
||||
</Route>
|
||||
<Route path="/wiki" element={<WikiSearchPage/>}/>
|
||||
<Route path="/wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="/wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="/wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="/wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="/help" element={<HelpListPage/>}/>
|
||||
<Route path="/help/changes" element={<HelpChangesPage/>}/>
|
||||
<Route path="/help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||
<Route path="/help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="/users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="/settings" element={<Navigate to="/users/settings" replace/>}/>
|
||||
<Route path="/tos" element={<TOSPage/>}/>
|
||||
<Route path="/gekanator" element={<GekanatorPage user={user}/>}/>
|
||||
<Route path="/more" element={<MorePage/>}/>
|
||||
<Route path="wiki" element={<WikiSearchPage/>}/>
|
||||
<Route path="wiki/:title" element={<WikiDetailPage/>}/>
|
||||
<Route path="wiki/new" element={<WikiNewPage user={user}/>}/>
|
||||
<Route path="wiki/:id/edit" element={<WikiEditPage user={user}/>}/>
|
||||
<Route path="wiki/:id/diff" element={<WikiDiffPage/>}/>
|
||||
<Route path="wiki/changes" element={<WikiHistoryPage/>}/>
|
||||
<Route path="help" element={<HelpListPage/>}/>
|
||||
<Route path="help/changes" element={<HelpChangesPage/>}/>
|
||||
<Route path="help/:identifier/diff" element={<HelpDiffPage/>}/>
|
||||
<Route path="help/:identifier" element={<HelpDetailPage/>}/>
|
||||
<Route path="users/settings" element={<SettingPage user={user} setUser={setUser}/>}/>
|
||||
<Route path="settings" element={<LocaleRedirect to="/users/settings"/>}/>
|
||||
<Route path="tos" element={<TOSPage/>}/>
|
||||
<Route path="gekanator" element={<GekanatorPage user={user}/>}/>
|
||||
<Route path="more" element={<MorePage/>}/>
|
||||
<Route path="*" element={<NotFound/>}/>
|
||||
</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 [status, setStatus] = useState (200)
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
@@ -243,7 +251,7 @@ const RoutedApp: FC = () => {
|
||||
layout={animationMode === 'off' ? false : 'position'}
|
||||
transition={{ layout: appLayoutTransition }}
|
||||
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||
<TopNav user={user}/>
|
||||
<TopNav user={user} locales={locales}/>
|
||||
<RouteTransitionWrapper
|
||||
animationMode={animationMode}
|
||||
user={user}
|
||||
@@ -259,9 +267,70 @@ const RoutedApp: FC = () => {
|
||||
}
|
||||
|
||||
|
||||
const router = createBrowserRouter ([{
|
||||
path: '*',
|
||||
element: <RoutedApp/> }])
|
||||
const LocaleRoute: FC = () => {
|
||||
const { locale } = useParams ()
|
||||
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 = () => (
|
||||
|
||||
@@ -8,6 +8,8 @@ const COLUMN_COUNT = 12
|
||||
|
||||
|
||||
const DevModeWatermark: FC = () => {
|
||||
const { t } = useTranslation ('shared')
|
||||
const text = t ('labels.development', { returnDetails: true })
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
@@ -29,13 +31,14 @@ const DevModeWatermark: FC = () => {
|
||||
{Array.from ({ length: COLUMN_COUNT }, (_, columnIndex) => (
|
||||
<span
|
||||
key={columnIndex}
|
||||
lang={text.usedLng}
|
||||
className={
|
||||
'whitespace-nowrap text-3xl font-bold '
|
||||
+ 'tracking-[0.3em] text-neutral-950/5 '
|
||||
+ 'dark:text-white/10'
|
||||
}
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
開発モード
|
||||
{text.res}
|
||||
</span>))}
|
||||
</div>))}
|
||||
</div>
|
||||
@@ -44,3 +47,5 @@ const DevModeWatermark: FC = () => {
|
||||
|
||||
|
||||
export default DevModeWatermark
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import nikumaru from '@/assets/fonts/nikumaru.otf'
|
||||
import errorImg from '@/assets/images/not-found.gif'
|
||||
import MainArea from '@/components/layout/MainArea'
|
||||
import { SITE_TITLE } from '@/config'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -11,17 +12,16 @@ type Props = { status: number }
|
||||
|
||||
|
||||
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)
|
||||
{
|
||||
case 403:
|
||||
return ['権限ないよ(笑)', '帰れ!', '帰れ!']
|
||||
case 404:
|
||||
return ['ページないよ(笑)', '帰れ!', '帰れ!']
|
||||
case 500:
|
||||
return ['鯖でエラー出たって(嘲笑)', '管理人に', '聯絡を!']
|
||||
case 503:
|
||||
return ['鯖死んでるよ(泣)', '管理人に', '聯絡を!']
|
||||
return [translate (`${ status }.message`).text,
|
||||
translate (`${ status }.right`).text, translate (`${ status }.left`).text]
|
||||
default:
|
||||
throw new Error
|
||||
}
|
||||
@@ -51,13 +51,13 @@ const ErrorScreen: FC<Props> = ({ status }) => {
|
||||
style={{ fontFamily: 'Nikumaru' }}>
|
||||
<p>{status}</p>
|
||||
<div className="flex flex-row space-x-1 sm:space-x-2 md:space-x-4">
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{leftMsg}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt="逃げたギター"/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{rightMsg}</p>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.left`)}</p>
|
||||
<img className="max-w-[70vw]" src={errorImg} alt={translate ('imageAlt').text}/>
|
||||
<p style={{ writingMode: 'vertical-rl' }}>{label (`${ status }.right`)}</p>
|
||||
</div>
|
||||
<p className="mr-[-.5em]">{message}</p>
|
||||
<p className="mr-[-.5em]">{label (`${ status }.message`)}</p>
|
||||
</div>
|
||||
</MainArea>)
|
||||
}
|
||||
|
||||
export default ErrorScreen
|
||||
export default ErrorScreen
|
||||
|
||||
@@ -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
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
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 TagLink from '@/components/TagLink'
|
||||
import { fetchMaterialTagTree, parseMaterialFilter } from '@/lib/materials'
|
||||
@@ -83,7 +84,7 @@ const tagSelectionShellClass = (selected: boolean): string =>
|
||||
const updateMaterialFilterQuery = (
|
||||
pathname: string,
|
||||
locationSearch: string,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
navigate: ReturnType<typeof useLocaleNavigate>,
|
||||
materialFilter: MaterialFilter,
|
||||
) => {
|
||||
const qs = new URLSearchParams (locationSearch)
|
||||
@@ -125,10 +126,11 @@ const MaterialTreeNode: FC<{
|
||||
tag: MaterialSidebarTag
|
||||
}> = ({ materialFilter, nestLevel = 0, onChildren, openTags, selectedTagId,
|
||||
setOpenTags, tag }) => {
|
||||
const locale = useLocale ()
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
@@ -200,6 +202,7 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
||||
tag,
|
||||
},
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const open = Boolean (openTags[tag.id])
|
||||
const tagColumnRef = 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 [childAvailableInlineSizePx, setChildAvailableInlineSizePx] = useState<number | null> (null)
|
||||
const { data } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }),
|
||||
queryKey: materialsKeys.tree ({ parentId: tag.id, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: tag.id, materialFilter }, locale),
|
||||
enabled: open && tag.hasChildren && tag.children.length === 0})
|
||||
|
||||
useEffect (() => {
|
||||
@@ -382,8 +385,9 @@ const MobileMaterialTreeNode: FC<{ depth?: number
|
||||
|
||||
|
||||
const MaterialSidebar: FC = () => {
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const qs = new URLSearchParams (location.search)
|
||||
const materialFilter = parseMaterialFilter (qs.get ('material_filter'), 'any')
|
||||
const selectedTagId = Number (qs.get ('tag_id') ?? 0) || null
|
||||
@@ -395,8 +399,8 @@ const MaterialSidebar: FC = () => {
|
||||
useState<number | null> (null)
|
||||
|
||||
const { data: rootTags = [], isLoading, isError } = useQuery ({
|
||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter })})
|
||||
queryKey: materialsKeys.tree ({ parentId: null, materialFilter }, locale),
|
||||
queryFn: () => fetchMaterialTagTree ({ parentId: null, materialFilter }, locale)})
|
||||
|
||||
useEffect (() => {
|
||||
setDesktopTags (rootTags)
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { FC } from 'react'
|
||||
|
||||
const MenuSeparator: FC = () => (
|
||||
<>
|
||||
<span className="hidden md:inline flex items-center px-2">|</span>
|
||||
<hr className="block md:hidden w-full opacity-25
|
||||
<span className="top-nav-separator-vertical items-center px-2">|</span>
|
||||
<hr className="top-nav-separator-horizontal w-full opacity-25
|
||||
border-t border-black dark:border-white"/>
|
||||
</>)
|
||||
|
||||
export default MenuSeparator
|
||||
export default MenuSeparator
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import FieldError from '@/components/common/FieldError'
|
||||
import PostDurationField from '@/components/posts/PostDurationField'
|
||||
@@ -51,6 +52,7 @@ type Props = { post: Post
|
||||
|
||||
|
||||
const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
const locale = useLocale ()
|
||||
const [disabled, setDisabled] = useState (false)
|
||||
const [duration, setDuration] = useState<string> (videoMsToDurationValue (post.videoMs))
|
||||
const { baseErrors, fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
@@ -75,7 +77,7 @@ const PostEditForm: FC<Props> = ({ post, onSave }) => {
|
||||
|
||||
try
|
||||
{
|
||||
const data = await updatePost (...args)
|
||||
const data = await updatePost (...args, undefined, locale)
|
||||
onSave ({ ...post,
|
||||
versionNo: data.versionNo,
|
||||
title: data.title,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import FormField from '@/components/common/FormField'
|
||||
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 { label } = useUITranslation ('navigation')
|
||||
const locale = useLocale ()
|
||||
const ref = useRef<HTMLTextAreaElement> (null)
|
||||
|
||||
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 })
|
||||
|
||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
||||
q: token,
|
||||
nico: '0' } })
|
||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||
@@ -77,7 +81,7 @@ const PostFormTagsArea: FC<Props> = ({ tags, setTags, errors, ...rest }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField className="relative w-full" label="タグ" messages={errors}>
|
||||
<FormField className="relative w-full" label={label ('tags')} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<TextArea
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSharedTransitionStore } from '@/stores/sharedTransitionStore'
|
||||
@@ -16,6 +17,7 @@ type Props = { posts: Post[]
|
||||
|
||||
|
||||
const PostList: FC<Props> = ({ posts, onClick }) => {
|
||||
const { label } = useUITranslation ('shared')
|
||||
const location = useLocation ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
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
|
||||
bg-muted text-center text-xs text-muted-foreground">
|
||||
サムネイルなし
|
||||
{label ('labels.noThumbnail')}
|
||||
</div>)
|
||||
: (
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import DateTimeField, { toMinutePrecisionIsoUtc } from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -19,9 +20,11 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
setOriginalCreatedFrom,
|
||||
originalCreatedBefore,
|
||||
setOriginalCreatedBefore,
|
||||
errors }: Props) => {
|
||||
errors }: Props,
|
||||
) => {
|
||||
const { label } = useUITranslation ('shared')
|
||||
return (
|
||||
<FormField label="オリジナルの作成日時" messages={errors}>
|
||||
<FormField label={label ('labels.originalCreatedAt')} messages={errors}>
|
||||
{({ describedBy, invalid }) => (
|
||||
<>
|
||||
<div className="my-1 flex">
|
||||
@@ -46,7 +49,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
d.setMinutes (d.getMinutes () + 1)
|
||||
setOriginalCreatedBefore (toMinutePrecisionIsoUtc (d.toISOString ()))
|
||||
}}/>
|
||||
以降
|
||||
{label ('labels.since')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
@@ -56,7 +59,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
onClick={() => {
|
||||
setOriginalCreatedFrom (null)
|
||||
}}>
|
||||
リセット
|
||||
{label ('actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,7 +74,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
invalid={invalid}
|
||||
value={originalCreatedBefore ?? undefined}
|
||||
onChange={setOriginalCreatedBefore}/>
|
||||
より前
|
||||
{label ('labels.before')}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
@@ -81,7 +84,7 @@ const PostOriginalCreatedTimeField: FC<Props> = (
|
||||
onClick={() => {
|
||||
setOriginalCreatedBefore (null)
|
||||
}}>
|
||||
リセット
|
||||
{label ('actions.reset')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { forwardRef, useMemo } from 'react'
|
||||
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 { prefetchForURL } from '@/lib/prefetchers'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
@@ -32,13 +34,17 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
if ('onClick' in rest)
|
||||
delete rest['onClick']
|
||||
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const locale = useLocale ()
|
||||
const qc = useQueryClient ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||
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 (() => {
|
||||
return (new URL (path, window.location.origin)).toString ()
|
||||
@@ -98,7 +104,7 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
||||
if (!(ok) && cancelOnError)
|
||||
return
|
||||
|
||||
navigate (to, { replace, ...(state && { state }) })
|
||||
navigate (path, { replace, ...(state && { state }) })
|
||||
}
|
||||
catch (ex)
|
||||
{
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
||||
export default <T extends string,>({ by, label, currentOrder, defaultDirection }: {
|
||||
by: T
|
||||
label: string
|
||||
label: ReactNode
|
||||
currentOrder: `${ T }:${ 'asc' | 'desc' }`
|
||||
defaultDirection: Record<T, 'asc' | 'desc'> }) => {
|
||||
const [fld, dir] = currentOrder.split (':')
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TagLink from '@/components/TagLink'
|
||||
@@ -27,6 +28,7 @@ import { postsKeys, tagsKeys } from '@/lib/queryKeys'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { dateString, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { CollisionDetection, DragEndEvent } from '@dnd-kit/core'
|
||||
import type { FC, MutableRefObject, ReactNode } from 'react'
|
||||
|
||||
@@ -180,8 +182,10 @@ const buildFlatTagByCategory = (
|
||||
|
||||
const changeCategory = async (
|
||||
tagId: number,
|
||||
category: Category): Promise<void> => {
|
||||
await apiPatch (`/tags/${ tagId }`, { category })
|
||||
category: 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 locale = useLocale ()
|
||||
sp = Boolean (sp)
|
||||
|
||||
const qc = useQueryClient ()
|
||||
@@ -271,7 +276,7 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 250, tolerance: 8 } }))
|
||||
|
||||
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: tagsKeys.root })
|
||||
}
|
||||
@@ -314,9 +319,9 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
return
|
||||
|
||||
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 ()
|
||||
toast ({
|
||||
@@ -333,10 +338,10 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
|
||||
return
|
||||
|
||||
if (child.category !== cat)
|
||||
await changeCategory (childId, cat)
|
||||
await changeCategory (childId, cat, locale)
|
||||
|
||||
if (fromParentId != null)
|
||||
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`)
|
||||
await apiDelete (`/tags/${ fromParentId }/children/${ childId }`, { locale })
|
||||
|
||||
const fromParent = fromParentId == null ? null : findTag (tags, fromParentId)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import ResponsiveMarqueeText from '@/components/ResponsiveMarqueeText'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ComponentProps, CSSProperties, FC, HTMLAttributes } from 'react'
|
||||
@@ -67,6 +68,10 @@ const TagLink: FC<Props> = ({ tag,
|
||||
style,
|
||||
title,
|
||||
...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 = {
|
||||
'--tag-link-colour': `var(--tag-colour-${ tag.category })`,
|
||||
'--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) }`}
|
||||
className="animate-[wiki-blink_.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>)
|
||||
: (
|
||||
@@ -131,7 +137,8 @@ const TagLink: FC<Props> = ({ tag,
|
||||
to={`/tags/${ tag.id }/deerjikists`}
|
||||
className="animate-[wiki-blink_.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>)
|
||||
: (
|
||||
@@ -139,7 +146,8 @@ const TagLink: FC<Props> = ({ tag,
|
||||
to={`/wiki/${ encodeURIComponent (tag.name) }`}
|
||||
className="animate-[wiki-blink_.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>)))}
|
||||
</span>)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||
|
||||
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 { inputClass } from '@/lib/utils'
|
||||
|
||||
@@ -14,8 +16,10 @@ import type { Tag } from '@/types'
|
||||
|
||||
|
||||
const TagSearch: FC = () => {
|
||||
const { message } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [search, setSearch] = useState ('')
|
||||
@@ -32,7 +36,7 @@ const TagSearch: FC = () => {
|
||||
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))
|
||||
if (suggestions.length > 0)
|
||||
setSuggestionsVsbl (true)
|
||||
@@ -106,7 +110,7 @@ const TagSearch: FC = () => {
|
||||
<div className="relative w-full">
|
||||
<input type="text"
|
||||
data-shortcut-focus-search="true"
|
||||
placeholder="タグ検索..."
|
||||
placeholder={message ('labels.tagSearch').text}
|
||||
value={search}
|
||||
onChange={whenChanged}
|
||||
onFocus={() => setSuggestionsVsbl (true)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
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 TagSearch from '@/components/TagSearch'
|
||||
import SectionTitle from '@/components/common/SectionTitle'
|
||||
@@ -22,7 +24,9 @@ type Props = { posts: Post[]
|
||||
|
||||
|
||||
const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
const navigate = useNavigate ()
|
||||
const { label } = useUITranslation ('shared')
|
||||
const locale = useLocale ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
const behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
const animationsOff = animationMode === 'off'
|
||||
@@ -69,7 +73,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
|
||||
const TagBlock = (
|
||||
<>
|
||||
<SectionTitle>タグ</SectionTitle>
|
||||
<SectionTitle>{label ('navigation:tags')}</SectionTitle>
|
||||
<ul>
|
||||
{CATEGORIES.flatMap (cat => cat in tags ? (
|
||||
tags[cat].map (tag => (
|
||||
@@ -88,7 +92,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
</motion.div>)}
|
||||
</li>))) : [])}
|
||||
</ul>
|
||||
<SectionTitle>関聯</SectionTitle>
|
||||
<SectionTitle>{label ('labels.relations')}</SectionTitle>
|
||||
<a href="#"
|
||||
onClick={ev => {
|
||||
ev.preventDefault ()
|
||||
@@ -96,7 +100,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
try
|
||||
{
|
||||
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') } })
|
||||
navigate (`/posts/${ data.id }`)
|
||||
}
|
||||
@@ -106,7 +110,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
}
|
||||
}) ())
|
||||
}}>
|
||||
ランダム
|
||||
{label ('actions.random')}
|
||||
</a>
|
||||
</>)
|
||||
|
||||
@@ -146,7 +150,7 @@ const TagSidebar: FC<Props> = ({ posts, onClick }) => {
|
||||
ev.preventDefault ()
|
||||
setTagsVsbl (v => !(v))
|
||||
}}>
|
||||
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
|
||||
{tagsVsbl ? label ('labels.hideTags') : label ('labels.showTags')}
|
||||
</a>
|
||||
</SidebarComponent>)
|
||||
}
|
||||
|
||||
+222
-113
@@ -1,12 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import TopNavUser from '@/components/TopNavUser'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
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 { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
import { canEditContent } from '@/lib/users'
|
||||
@@ -17,20 +27,30 @@ import { fetchWikiPage } from '@/lib/wiki'
|
||||
|
||||
import type { FC, MouseEvent } from 'react'
|
||||
|
||||
import type { AvailableLocale } from '@/lib/locales'
|
||||
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'
|
||||
|
||||
|
||||
export const menuOutline = (
|
||||
{ tag, material, wikiId, user, pathName }: {
|
||||
{ tag, material, wikiId, user, pathName: fullPathname }: {
|
||||
tag?: Tag | null
|
||||
material?: Material | null
|
||||
wikiId: number | null
|
||||
user: User | null
|
||||
pathName: string },
|
||||
): 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 editable = canEditContent (user)
|
||||
|
||||
@@ -41,71 +61,75 @@ export const menuOutline = (
|
||||
|
||||
const materialFlg = /^\/materials\/\d+/.test (pathName)
|
||||
|
||||
const helpVsbl = /\/help\/((?!changes)[^\/?]+)/.test (pathName)
|
||||
const helpVsbl = /\/help\/((?!changes)[^/?]+)/.test (pathName)
|
||||
const helpIdentifier = pathName.split ('/')[2] ?? ''
|
||||
|
||||
return [
|
||||
{ name: '広場', to: '/posts', subMenu: [
|
||||
{ name: '一覧', to: '/posts' },
|
||||
{ name: '検索', to: '/posts/search' },
|
||||
{ name: '追加', to: '/posts/new', visible: editable },
|
||||
{ name: '全体履歴', to: '/posts/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
|
||||
{ name: 'タグ', to: '/tags', subMenu: [
|
||||
{ name: 'マスタ', to: '/tags' },
|
||||
{ name: 'ニコニコ連携', to: '/tags/nico' },
|
||||
{ name: '全体履歴', to: '/tags/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
|
||||
{ ...message ('posts'), to: '/posts', subMenu: [
|
||||
{ ...message ('list'), to: '/posts' },
|
||||
{ ...message ('search'), to: '/posts/search' },
|
||||
{ ...message ('add'), to: '/posts/new', visible: editable },
|
||||
{ ...message ('allHistory'), to: '/posts/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:広場' }] },
|
||||
{ ...message ('tags'), to: '/tags', subMenu: [
|
||||
{ ...message ('master'), to: '/tags' },
|
||||
{ ...message ('nicoLinks'), to: '/tags/nico' },
|
||||
{ ...message ('allHistory'), to: '/tags/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:タグ' },
|
||||
{ component: <Separator/>, visible: tagFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`,
|
||||
{ ...message ('postsWithCount', { count: postCount }),
|
||||
to: `/posts?tags=${ encodeURIComponent (tag?.name ?? '') }`,
|
||||
visible: tagFlg },
|
||||
{ name: '履歴', to: `/tags/changes?id=${ tag?.id }`,
|
||||
{ ...message ('history'), to: `/tags/changes?id=${ tag?.id }`,
|
||||
visible: tagFlg && tag?.category !== 'nico' }] },
|
||||
{ name: '素材', to: '/materials', visible: true, subMenu: [
|
||||
{ name: '一覧', to: '/materials' },
|
||||
{ name: '追加', to: '/materials/new', visible: editable },
|
||||
{ name: '抑止', to: '/materials/suppressions',
|
||||
{ ...message ('materials'), to: '/materials', visible: true, subMenu: [
|
||||
{ ...message ('list'), to: '/materials' },
|
||||
{ ...message ('add'), to: '/materials/new', visible: editable },
|
||||
{ ...message ('suppressions'), to: '/materials/suppressions',
|
||||
visible: user?.role === 'admin' },
|
||||
{ name: '全体履歴', to: '/materials/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材管理' },
|
||||
{ ...message ('allHistory'), to: '/materials/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:素材管理' },
|
||||
{ component: <Separator/>, visible: materialFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`,
|
||||
{ ...message ('postsWithCount', { count: postCount }),
|
||||
to: `/posts?tags=${ encodeURIComponent (material?.tag?.name ?? '') }`,
|
||||
visible: materialFlg && Boolean (material?.tag) },
|
||||
{ name: '履歴', to: `/materials/changes?material_id=${ material?.id }`,
|
||||
{ ...message ('history'), to: `/materials/changes?material_id=${ material?.id }`,
|
||||
visible: materialFlg }] },
|
||||
{ name: 'Wiki', to: '/wiki', subMenu: [
|
||||
{ name: '検索', to: '/wiki' },
|
||||
{ name: '新規', to: '/wiki/new', visible: editable },
|
||||
{ name: '全体履歴', to: '/wiki/changes' },
|
||||
{ name: 'ヘルプ', to: '/wiki/ヘルプ:Wiki' },
|
||||
{ ...message ('wiki'), to: '/wiki', subMenu: [
|
||||
{ ...message ('search'), to: '/wiki' },
|
||||
{ ...message ('create'), to: '/wiki/new', visible: editable },
|
||||
{ ...message ('allHistory'), to: '/wiki/changes' },
|
||||
{ ...message ('help'), to: '/wiki/ヘルプ:Wiki' },
|
||||
{ component: <Separator/>, visible: wikiPageFlg },
|
||||
{ name: `広場 (${ postCount || 0 })`, to: `/posts?tags=${ wikiTitle }`,
|
||||
{ ...message ('postsWithCount', { count: postCount }), to: `/posts?tags=${ wikiTitle }`,
|
||||
visible: wikiPageFlg },
|
||||
{ name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
{ ...message ('history'), to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg },
|
||||
{ ...message ('edit'), to: `/wiki/${ wikiId || wikiTitle }/edit`,
|
||||
visible: wikiPageFlg && editable }] },
|
||||
{ name: 'ヘルプ', to: '/help/home', base: '/help', subMenu: [
|
||||
{ name: '検索', to: '/help' },
|
||||
{ name: '全体履歴', to: '/help/changes' },
|
||||
{ ...message ('help'), to: '/help/home', base: '/help', subMenu: [
|
||||
{ ...message ('search'), to: '/help' },
|
||||
{ ...message ('allHistory'), to: '/help/changes' },
|
||||
{ component: <Separator/>, visible: helpVsbl },
|
||||
{ name: '履歴',
|
||||
{ ...message ('history'),
|
||||
to: `/help/changes?identifier=${ helpIdentifier }`,
|
||||
visible: helpVsbl }] },
|
||||
{ name: 'おたのしみ', visible: false, subMenu: [
|
||||
{ name: '上映会 (β)', to: '/theatres/1' },
|
||||
{ name: 'グカネータ (β)', to: '/gekanator' }] },
|
||||
{ name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [
|
||||
{ name: '一覧', to: '/users', visible: false },
|
||||
{ name: 'お前', to: `/users/${ user?.id }`, visible: false },
|
||||
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] },
|
||||
{ name: '法規', visible: false, subMenu: [
|
||||
{ name: '利用規約', to: '/tos' }] }]
|
||||
{ ...message ('entertainment'), visible: false, subMenu: [
|
||||
{ ...message ('theatre'), to: '/theatres/1' },
|
||||
{ ...message ('gekanator'), to: '/gekanator' }] },
|
||||
{ ...message ('users'), to: '/users/settings', visible: false, subMenu: [
|
||||
{ ...message ('list'), to: '/users', visible: false },
|
||||
{ ...message ('currentUser'), to: `/users/${ user?.id }`, visible: false },
|
||||
{ ...message ('settings'), to: '/users/settings', visible: Boolean (user) }] },
|
||||
{ ...message ('legal'), visible: false, subMenu: [
|
||||
{ ...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 behaviourSettings = useClientBehaviourSettings ()
|
||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||
@@ -117,6 +141,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
const navRef = useRef<HTMLDivElement | null> (null)
|
||||
const layoutRef = useRef<HTMLDivElement | null> (null)
|
||||
const barRef = useRef<HTMLElement | null> (null)
|
||||
const submenuContentRef = useRef<HTMLDivElement | null> (null)
|
||||
|
||||
const measure = (idx: number) => {
|
||||
const nav = navRef.current
|
||||
@@ -145,31 +170,33 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
const [density, setDensity] = useState<NavDensity> ('auto')
|
||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||
|
||||
const wikiIdStr = String (wikiId ?? '')
|
||||
|
||||
const { data: wikiPage } = useQuery ({
|
||||
enabled: Boolean (wikiIdStr),
|
||||
queryKey: wikiKeys.show (wikiIdStr, { }),
|
||||
queryFn: () => fetchWikiPage (wikiIdStr, { }) })
|
||||
queryKey: wikiKeys.show (wikiIdStr, { }, locale),
|
||||
queryFn: () => fetchWikiPage (wikiIdStr, { }, locale) })
|
||||
|
||||
const tagFlg = /^\/tags\/\d+/.test (location.pathname)
|
||||
const materialFlg = /^\/materials\/\d+/.test (location.pathname)
|
||||
const pathname = unlocalisedPath (location.pathname)
|
||||
const tagFlg = /^\/tags\/\d+/.test (pathname)
|
||||
const materialFlg = /^\/materials\/\d+/.test (pathname)
|
||||
const effectiveTitle = (((tagFlg || materialFlg)
|
||||
? location.pathname.split ('/')[2]
|
||||
? pathname.split ('/')[2]
|
||||
: wikiPage?.title)
|
||||
?? '')
|
||||
|
||||
const { data: tag } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle) })
|
||||
queryKey: tagsKeys.show (effectiveTitle, locale),
|
||||
queryFn: () => (tagFlg ? fetchTag : fetchTagByName) (effectiveTitle, locale) })
|
||||
|
||||
const { data: material } = useQuery ({
|
||||
enabled: Boolean (effectiveTitle),
|
||||
queryKey: materialsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchMaterial (effectiveTitle) })
|
||||
queryKey: materialsKeys.show (effectiveTitle, locale),
|
||||
queryFn: () => fetchMaterial (effectiveTitle, locale) })
|
||||
|
||||
const menu = menuOutline ({ tag, material, wikiId, user, pathName: location.pathname })
|
||||
const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true)
|
||||
@@ -177,26 +204,25 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
!(item.visible ?? true)
|
||||
|| item.subMenu.filter (subItem => subItem.visible ?? true).length > 0)
|
||||
const activeIdx =
|
||||
visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to))
|
||||
const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40)
|
||||
visibleMenu.findIndex (item => pathname.startsWith (item.base || item.to))
|
||||
const topNavTransition =
|
||||
animationsOff
|
||||
? { duration: 0 }
|
||||
: reducedAnimations
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const }
|
||||
: (reducedAnimations
|
||||
? { duration: .08, ease: 'linear' as const }
|
||||
: { duration: .2, ease: 'easeOut' as const })
|
||||
const opacityTransition =
|
||||
animationsOff
|
||||
? { duration: 0 }
|
||||
: reducedAnimations
|
||||
? { duration: .08 }
|
||||
: { duration: .12 }
|
||||
: (reducedAnimations
|
||||
? { duration: .08 }
|
||||
: { duration: .12 })
|
||||
const highlightTransitionClass =
|
||||
animationsOff
|
||||
? undefined
|
||||
: reducedAnimations
|
||||
? 'transition-[transform,width] duration-75 ease-linear'
|
||||
: 'transition-[transform,width] duration-200 ease-out'
|
||||
: (reducedAnimations
|
||||
? 'transition-[transform,width] duration-75 ease-linear'
|
||||
: 'transition-[transform,width] duration-200 ease-out')
|
||||
|
||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||
|
||||
@@ -215,7 +241,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
if (layout == null || bar == null || primary == null)
|
||||
return
|
||||
|
||||
const userLink = bar.querySelector<HTMLElement> ('.top-nav-user-desktop')
|
||||
const account = bar.querySelector<HTMLElement> ('.top-nav-account')
|
||||
let raf = 0
|
||||
let active = true
|
||||
|
||||
@@ -237,11 +263,16 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
{
|
||||
layout.setAttribute ('data-density', candidate)
|
||||
const style = getComputedStyle (bar)
|
||||
const required = primary.getBoundingClientRect ().width
|
||||
+ (userLink?.getBoundingClientRect ().width ?? 0)
|
||||
const primaryWidth = Math.max (
|
||||
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)
|
||||
+ (userLink == null ? 0 : parseFloat (style.columnGap))
|
||||
if (required <= bar.clientWidth)
|
||||
+ (account == null ? 0 : parseFloat (style.columnGap))
|
||||
if (Math.ceil (required) <= bar.clientWidth)
|
||||
return candidate
|
||||
}
|
||||
return null
|
||||
@@ -269,8 +300,8 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (schedule)
|
||||
observer?.observe (bar)
|
||||
observer?.observe (primary)
|
||||
if (userLink != null)
|
||||
observer?.observe (userLink)
|
||||
if (account != null)
|
||||
observer?.observe (account)
|
||||
update ()
|
||||
addEventListener ('resize', schedule)
|
||||
document.fonts?.addEventListener ('loadingdone', schedule)
|
||||
@@ -283,22 +314,68 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
removeEventListener ('resize', schedule)
|
||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||
}
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name])
|
||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales])
|
||||
|
||||
useEffect (() => {
|
||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||
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 (() => {
|
||||
setMenuOpen (false)
|
||||
setOpenItemIdx (activeIdx)
|
||||
}, [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 (
|
||||
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
|
||||
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">
|
||||
<div className="top-nav-primary flex shrink-0 items-center h-12">
|
||||
<PrefetchLink
|
||||
@@ -307,7 +384,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
onClick={() => {
|
||||
scroll (0, 0)
|
||||
}}>
|
||||
ぼざクリ タグ広場
|
||||
{label ('labels.brand')}
|
||||
</PrefetchLink>
|
||||
|
||||
<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',
|
||||
(i === openItemIdx) && 'font-bold')}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>))}
|
||||
<PrefetchLink
|
||||
@@ -355,22 +434,28 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
className={cn ('top-nav-menu-link relative z-10 flex h-full items-center',
|
||||
'shrink-0 whitespace-nowrap',
|
||||
(openItemIdx < 0 || moreVsbl) && 'font-bold')}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
</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
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
ml-auto border-0 bg-transparent pr-4"
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : 'Menu'}
|
||||
</button>
|
||||
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||
{menuOpen && localeSelector}
|
||||
<button
|
||||
type="button"
|
||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||
border-0 bg-transparent pr-4"
|
||||
onClick={() => {
|
||||
setMenuOpen (!(menuOpen))
|
||||
}}>
|
||||
{menuOpen ? '×' : 'Menu'}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -389,12 +474,11 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
onAnimationComplete={() => {
|
||||
measure (moreVsbl ? -1 : activeIdx)
|
||||
}}>
|
||||
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
||||
{moreVsbl
|
||||
? (
|
||||
moreMenu.map ((item, i) => (
|
||||
<div key={i} className="relative h-[40px]">
|
||||
<div className="absolute inset-0 flex items-center px-3
|
||||
overflow-x-auto whitespace-nowrap">
|
||||
<div key={i} className="contents">
|
||||
<motion.div
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
@@ -404,9 +488,14 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||
animate: { x: 0, y: 0, opacity: 1 },
|
||||
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||
className="z-10 h-full flex shrink-0 items-center px-3 font-bold w-28">
|
||||
<h2>{item.name}</h2>
|
||||
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||
<h2>
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</h2>
|
||||
</motion.div>
|
||||
<div className="top-nav-more-links flex min-w-0 flex-wrap items-center">
|
||||
{item.subMenu
|
||||
.filter (subItem => subItem.visible ?? true)
|
||||
.map ((subItem, j) => (
|
||||
@@ -414,6 +503,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }-${ j }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
? { }
|
||||
@@ -428,6 +518,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }-${ j }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={topNavTransition}
|
||||
{...(animationsOff
|
||||
? { }
|
||||
@@ -441,49 +532,52 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
to={subItem.to}
|
||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||
onClick={() => setMoreVsbl (false)}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{subItem.name}
|
||||
className="top-nav-menu-link flex min-h-[40px] items-center px-3 py-2">
|
||||
<LocalisedText language={subItem.nameLanguage ?? 'ja'}>
|
||||
{subItem.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</div>
|
||||
</div>)))
|
||||
: ((visibleMenu[activeIdx]?.subMenu ?? []).length > 0
|
||||
&& (
|
||||
<div className="relative h-[40px]">
|
||||
<div className="relative min-h-[40px]">
|
||||
{animationsOff
|
||||
? (
|
||||
<div className="absolute inset-0 flex items-center px-3
|
||||
overflow-x-auto whitespace-nowrap">
|
||||
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||
.filter (item => item.visible ?? true)
|
||||
.map ((item, i) => (
|
||||
'component' in item
|
||||
? (
|
||||
<div key={`c-${ i }`}>
|
||||
<div key={`c-${ i }`} className="min-w-0 max-w-full">
|
||||
{item.component}
|
||||
</div>)
|
||||
: (
|
||||
<div key={`l-${ i }`}>
|
||||
<div key={`l-${ i }`} className="min-w-0 max-w-full">
|
||||
<PrefetchLink
|
||||
to={item.to}
|
||||
target={item.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{item.name}
|
||||
className="top-nav-menu-link flex min-h-[40px]
|
||||
items-center px-3 py-2">
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</div>)))}
|
||||
</div>)
|
||||
: (
|
||||
<AnimatePresence initial={false} custom={dir}>
|
||||
<AnimatePresence initial={false} custom={dir} mode="popLayout">
|
||||
<motion.div
|
||||
key={activeIdx}
|
||||
custom={dir}
|
||||
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||
centre: { y: 0, opacity: 1 },
|
||||
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||
className="absolute inset-0 flex items-center px-3
|
||||
overflow-x-auto whitespace-nowrap"
|
||||
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
||||
initial="enter"
|
||||
animate="centre"
|
||||
exit="exit"
|
||||
@@ -495,6 +589,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? (
|
||||
<motion.div
|
||||
key={`c-${ i }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={{ layout: topNavTransition }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
{item.component}
|
||||
@@ -502,6 +597,7 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
: (
|
||||
<motion.div
|
||||
key={`l-${ i }`}
|
||||
className="min-w-0 max-w-full"
|
||||
transition={{ layout: topNavTransition }}
|
||||
layoutId={`submenu-${ visibleMenu[activeIdx].name }-${ i }`}>
|
||||
<PrefetchLink
|
||||
@@ -509,13 +605,17 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
target={item.to.slice (0, 2) === '//'
|
||||
? '_blank'
|
||||
: undefined}
|
||||
className="top-nav-menu-link h-full flex items-center px-3">
|
||||
{item.name}
|
||||
className="top-nav-menu-link flex min-h-[40px]
|
||||
items-center px-3 py-2">
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
</motion.div>)))}
|
||||
</motion.div>
|
||||
</AnimatePresence>)}
|
||||
</div>))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -540,7 +640,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
|
||||
{i === openItemIdx && (
|
||||
@@ -561,7 +663,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? '_blank'
|
||||
: undefined}
|
||||
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>)))}
|
||||
</div>)}
|
||||
</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',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
@@ -605,7 +709,9 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
setOpenItemIdx (i)
|
||||
}
|
||||
}}>
|
||||
{item.name}
|
||||
<LocalisedText language={item.nameLanguage ?? 'ja'}>
|
||||
{item.name}
|
||||
</LocalisedText>
|
||||
</PrefetchLink>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
@@ -639,7 +745,10 @@ const TopNav: FC<Props> = ({ user }) => {
|
||||
? '_blank'
|
||||
: undefined}
|
||||
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>)))}
|
||||
</motion.div>)}
|
||||
</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',
|
||||
((openItemIdx < 0)
|
||||
&& 'top-nav-mobile-active font-bold'))}>
|
||||
その他 »
|
||||
<TranslatedText messageKey="more"/>
|
||||
</PrefetchLink>
|
||||
<TopNavUser user={user} sp/>
|
||||
<Separator/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Separator from '@/components/MenuSeparator'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -11,6 +12,7 @@ type Props = { user: User | null,
|
||||
|
||||
|
||||
const TopNavUser: FC<Props> = ({ user, sp }) => {
|
||||
const { label } = useUITranslation ('shared')
|
||||
if (!(user))
|
||||
return
|
||||
|
||||
@@ -23,9 +25,8 @@ const TopNavUser: FC<Props> = ({ user, sp }) => {
|
||||
return (
|
||||
<>
|
||||
{sp && <Separator/>}
|
||||
<PrefetchLink to="/users/settings"
|
||||
className={className}>
|
||||
{user.name || '名もなきニジラー'}
|
||||
<PrefetchLink to="/users/settings" className={className}>
|
||||
{user.name || label ('labels.anonymous')}
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
type Props = {
|
||||
messageKey: string
|
||||
namespace?: string
|
||||
values?: Record<string, string | number>
|
||||
className?: string }
|
||||
|
||||
|
||||
const TranslatedText = (
|
||||
{ messageKey, namespace = 'navigation', values, className }: Props,
|
||||
) => {
|
||||
const { message } = useUITranslation (namespace)
|
||||
const result = message (messageKey, values)
|
||||
return (
|
||||
<LocalisedText language={result.language} className={className}>
|
||||
{result.text}
|
||||
</LocalisedText>)
|
||||
}
|
||||
|
||||
|
||||
export default TranslatedText
|
||||
@@ -3,6 +3,8 @@ import { useMemo } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGFM from 'remark-gfm'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { wikiKeys } from '@/lib/queryKeys'
|
||||
import remarkWikiAutoLink from '@/lib/remark-wiki-autolink'
|
||||
@@ -26,10 +28,12 @@ const mdComponents = { a: (({ href, children }) => (
|
||||
|
||||
|
||||
const WikiBody: FC<Props> = ({ title, body }) => {
|
||||
const { message } = useUITranslation ('wiki')
|
||||
const locale = useLocale ()
|
||||
const { data } = useQuery ({
|
||||
enabled: Boolean (body),
|
||||
queryKey: wikiKeys.index ({ }),
|
||||
queryFn: () => fetchWikiPages ({ }) })
|
||||
queryKey: wikiKeys.index ({ }, locale),
|
||||
queryFn: () => fetchWikiPages ({ }, locale) })
|
||||
const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length)
|
||||
|
||||
const remarkPlugins = useMemo (
|
||||
@@ -37,8 +41,9 @@ const WikiBody: FC<Props> = ({ title, body }) => {
|
||||
|
||||
return (
|
||||
<ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}>
|
||||
{body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`}
|
||||
{body || message ('missingPage', {
|
||||
url: `/wiki/new?title=${ encodeURIComponent (title) }` }).text}
|
||||
</ReactMarkdown>)
|
||||
}
|
||||
|
||||
export default WikiBody
|
||||
export default WikiBody
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -49,6 +50,7 @@ const getPages = (
|
||||
|
||||
const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
const location = useLocation ()
|
||||
const { message } = useUITranslation ()
|
||||
|
||||
const buildTo = (p: number) => {
|
||||
const qs = new URLSearchParams (location.search)
|
||||
@@ -59,7 +61,9 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
const pages = getPages (page, totalPages, siblingCount)
|
||||
|
||||
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">
|
||||
{(page > 1)
|
||||
? (
|
||||
@@ -67,13 +71,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (1)}
|
||||
aria-label="最初のページ">
|
||||
lang={message ('pagination.first').language}
|
||||
aria-label={message ('pagination.first').text}>
|
||||
|<
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page - 1)}
|
||||
aria-label="前のページ">
|
||||
lang={message ('pagination.previous').language}
|
||||
aria-label={message ('pagination.previous').text}>
|
||||
<
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
@@ -106,13 +112,15 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
|
||||
<PrefetchLink
|
||||
className="p-2"
|
||||
to={buildTo (page + 1)}
|
||||
aria-label="次のページ">
|
||||
lang={message ('pagination.next').language}
|
||||
aria-label={message ('pagination.next').text}>
|
||||
>
|
||||
</PrefetchLink>
|
||||
<PrefetchLink
|
||||
className="md:hidden p-2"
|
||||
to={buildTo (totalPages)}
|
||||
aria-label="最後のページ">
|
||||
lang={message ('pagination.last').language}
|
||||
aria-label={message ('pagination.last').text}>
|
||||
>|
|
||||
</PrefetchLink>
|
||||
</>)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import TagSearchBox from '@/components/TagSearchBox'
|
||||
import { apiGet } from '@/lib/api'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
@@ -24,6 +25,7 @@ const TagInput: FC<Props> = ({
|
||||
value,
|
||||
setValue,
|
||||
}) => {
|
||||
const locale = useLocale ()
|
||||
const [activeIndex, setActiveIndex] = useState (-1)
|
||||
const [suggestions, setSuggestions] = useState<Tag[]> ([])
|
||||
const [suggestionsVsbl, setSuggestionsVsbl] = useState (false)
|
||||
@@ -39,7 +41,7 @@ const TagInput: FC<Props> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: {
|
||||
const data = await apiGet<Tag[]> ('/tags/autocomplete', { locale, params: {
|
||||
q,
|
||||
nico: includeNico ? '1' : '0' } })
|
||||
const nextSuggestions = data.filter (t => t.postCount > 0)
|
||||
|
||||
@@ -88,8 +88,9 @@ const DialogueProvider: FC<Props> = ({ children }) => {
|
||||
})
|
||||
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
||||
setFormActions (current => {
|
||||
const { [id]: _, ...rest } = current
|
||||
return rest
|
||||
const rest = { ...current }
|
||||
delete rest[id]
|
||||
return rest
|
||||
})
|
||||
delete formControls.current[id]
|
||||
}, [])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField'
|
||||
import PostTagsField from '@/components/posts/PostTagsField'
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
@@ -54,7 +55,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
||||
) => (
|
||||
<>
|
||||
<PostTextField
|
||||
label="タイトル"
|
||||
label={<TranslatedText namespace="shared" messageKey="labels.title"/>}
|
||||
value={title.value}
|
||||
disabled={title.disabled}
|
||||
warnings={title.warnings}
|
||||
@@ -82,7 +83,7 @@ const PostCoreDataFields: FC<PostCoreDataFieldsProps> = (
|
||||
rows={tags.rows}/>
|
||||
|
||||
<PostTextField
|
||||
label="親投稿"
|
||||
label={<TranslatedText namespace="shared" messageKey="labels.parentPosts"/>}
|
||||
value={parentPostIds.value}
|
||||
disabled={parentPostIds.disabled}
|
||||
warnings={parentPostIds.warnings}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import PostTextField from '@/components/posts/PostTextField'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -16,12 +17,11 @@ const PostDurationField: FC<Props> = (
|
||||
disabled },
|
||||
) => (
|
||||
<PostTextField
|
||||
label="動画時間"
|
||||
label={<TranslatedText namespace="shared" messageKey="labels.duration"/>}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
errors={errors}
|
||||
disabled={disabled}
|
||||
type="text"/>
|
||||
)
|
||||
type="text"/>)
|
||||
|
||||
export default PostDurationField
|
||||
|
||||
@@ -5,7 +5,7 @@ import { inputClass } from '@/lib/utils'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
|
||||
type Props = {
|
||||
label: string
|
||||
label: ReactNode
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
warnings?: string[]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -15,10 +16,11 @@ type Props = {
|
||||
const PostThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
alt,
|
||||
className = 'h-16 w-16',
|
||||
referrerPolicy },
|
||||
) => {
|
||||
const { message } = useUITranslation ('shared')
|
||||
const [failed, setFailed] = useState (false)
|
||||
const [fileUrl, setFileUrl] = useState<string | null> (null)
|
||||
|
||||
@@ -55,7 +57,7 @@ const PostThumbnailPreview: FC<Props> = (
|
||||
return (
|
||||
<img
|
||||
src={resolvedUrl}
|
||||
alt={alt}
|
||||
alt={alt ?? message ('labels.thumbnail').text}
|
||||
referrerPolicy={referrerPolicy}
|
||||
className={cn (className, 'rounded border border-border object-cover')}
|
||||
onError={() => setFailed (true)}/>)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import StatusBadge from '@/components/common/StatusBadge'
|
||||
import TranslatedText from '@/components/TranslatedText'
|
||||
|
||||
import type { FC } from 'react'
|
||||
|
||||
@@ -8,14 +9,6 @@ import type { PostImportBadgeValue } from '@/components/posts/import/postImportR
|
||||
type Props = {
|
||||
value: PostImportBadgeValue }
|
||||
|
||||
const LABELS: Record<PostImportBadgeValue, string> = {
|
||||
ready: '登録可能',
|
||||
error: '登録不可',
|
||||
warning: '警告',
|
||||
skipped: 'スキップ',
|
||||
created: '登録済み',
|
||||
failed: '登録失敗' }
|
||||
|
||||
const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||
ready: 'success',
|
||||
error: 'warning',
|
||||
@@ -27,7 +20,7 @@ const TONES: Record<PostImportBadgeValue, StatusBadgeTone> = {
|
||||
|
||||
const PostImportStatusBadge: FC<Props> = ({ value }) => (
|
||||
<StatusBadge tone={TONES[value]}>
|
||||
{LABELS[value]}
|
||||
<TranslatedText namespace="imports" messageKey={`status.${ value }`}/>
|
||||
</StatusBadge>)
|
||||
|
||||
export default PostImportStatusBadge
|
||||
|
||||
@@ -12,7 +12,7 @@ type Props = {
|
||||
const PostImportThumbnailPreview: FC<Props> = (
|
||||
{ url,
|
||||
file,
|
||||
alt = 'サムネール',
|
||||
alt,
|
||||
className = 'h-16 w-16' },
|
||||
) => (
|
||||
<PostThumbnailPreview
|
||||
|
||||
+93
-1
@@ -4,6 +4,20 @@
|
||||
@tailwind components;
|
||||
@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
|
||||
{
|
||||
:root
|
||||
@@ -208,6 +222,12 @@ body
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
.top-nav-account
|
||||
{
|
||||
flex-shrink: 0;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.top-nav-layout .top-nav-brand-link
|
||||
{
|
||||
margin-inline: 4px;
|
||||
@@ -216,6 +236,7 @@ body
|
||||
|
||||
.top-nav-desktop-menu,
|
||||
.top-nav-desktop-submenu,
|
||||
.top-nav-layout .top-nav-account,
|
||||
.top-nav-user-desktop
|
||||
{
|
||||
display: none;
|
||||
@@ -226,6 +247,39 @@ body
|
||||
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
|
||||
{
|
||||
padding-inline: 8px;
|
||||
@@ -242,8 +296,42 @@ body
|
||||
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)
|
||||
{
|
||||
.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
|
||||
{
|
||||
font-size: 1.125rem;
|
||||
@@ -255,7 +343,9 @@ body
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
@@ -269,6 +359,8 @@ body
|
||||
|
||||
.top-nav-layout[data-desktop='true'] .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-user-mobile
|
||||
{
|
||||
|
||||
+11
-4
@@ -5,7 +5,10 @@ import { API_BASE_URL } from '@/config'
|
||||
|
||||
import type { AxiosError, AxiosRequestConfig } from 'axios'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
|
||||
type Opt = {
|
||||
locale?: LocaleCode
|
||||
params?: AxiosRequestConfig['params']
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
@@ -14,10 +17,14 @@ type Opt = {
|
||||
const client = axios.create ({ baseURL: API_BASE_URL })
|
||||
|
||||
|
||||
const withUserCode = (opt?: Opt): Opt => ({
|
||||
...opt,
|
||||
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
|
||||
...(opt?.headers ?? { }) } })
|
||||
const withUserCode = (opt?: Opt): AxiosRequestConfig => {
|
||||
const { locale, ...config } = opt ?? { }
|
||||
return {
|
||||
...config,
|
||||
...(locale != null && { params: { locale, ...config.params } }),
|
||||
headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '',
|
||||
...(config.headers ?? { }) } }
|
||||
}
|
||||
|
||||
|
||||
const apiP = async <T> (
|
||||
|
||||
+31
-15
@@ -1,5 +1,6 @@
|
||||
import { apiGet, apiPost } from '@/lib/api'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { Post } from '@/types'
|
||||
|
||||
export type GekanatorAnswerValue =
|
||||
@@ -411,24 +412,30 @@ export const storeGekanatorQuestion = (
|
||||
exampleAnswers: question.exampleAnswers })
|
||||
|
||||
|
||||
export const fetchGekanatorPosts = async (): Promise<Post[]> => {
|
||||
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts')
|
||||
export const fetchGekanatorPosts = async (
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<Post[]> => {
|
||||
const data = await apiGet<{ posts: Post[] }> ('/gekanator/posts', { locale })
|
||||
return data.posts
|
||||
}
|
||||
|
||||
|
||||
export const fetchGekanatorQuestions = async (): Promise<StoredGekanatorQuestion[]> => {
|
||||
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions')
|
||||
export const fetchGekanatorQuestions = async (
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<StoredGekanatorQuestion[]> => {
|
||||
const data = await apiGet<{ questions: StoredGekanatorQuestion[] }> ('/gekanator/questions', { locale })
|
||||
return data.questions
|
||||
}
|
||||
|
||||
|
||||
export const fetchGekanatorExtraQuestions = async (
|
||||
gameId: number,
|
||||
nonce?: string): Promise<GekanatorExtraQuestion[]> => {
|
||||
nonce?: string,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<GekanatorExtraQuestion[]> => {
|
||||
const data = await apiGet<{ questions: GekanatorExtraQuestion[] }> (
|
||||
`/gekanator/games/${ gameId }/extra_questions`,
|
||||
{ params: nonce ? { nonce } : undefined })
|
||||
{ locale, params: nonce ? { nonce } : undefined })
|
||||
return data.questions
|
||||
}
|
||||
|
||||
@@ -606,7 +613,8 @@ export const buildGekanatorQuestions = (
|
||||
}
|
||||
|
||||
|
||||
export const saveGekanatorGame = async ({
|
||||
export const saveGekanatorGame = async (
|
||||
{
|
||||
guessedPostId,
|
||||
correctPostId,
|
||||
answers,
|
||||
@@ -614,7 +622,9 @@ export const saveGekanatorGame = async ({
|
||||
guessedPostId: number
|
||||
correctPostId: number
|
||||
answers: GekanatorAnswerLog[]
|
||||
}): Promise<{ id: number; learnedExampleCount: number }> =>
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{ id: number; learnedExampleCount: number }> =>
|
||||
await apiPost ('/gekanator/games', {
|
||||
guessed_post_id: guessedPostId,
|
||||
correct_post_id: correctPostId,
|
||||
@@ -627,10 +637,11 @@ export const saveGekanatorGame = async ({
|
||||
effective_question: answer.effectiveQuestion,
|
||||
learning_question: answer.learningQuestion,
|
||||
answer: answer.answer,
|
||||
original_answer: answer.originalAnswer })) })
|
||||
original_answer: answer.originalAnswer })) }, { locale })
|
||||
|
||||
|
||||
export const saveGekanatorQuestionSuggestion = async ({
|
||||
export const saveGekanatorQuestionSuggestion = async (
|
||||
{
|
||||
gekanatorGameId,
|
||||
existingQuestionId,
|
||||
questionText,
|
||||
@@ -640,22 +651,27 @@ export const saveGekanatorQuestionSuggestion = async ({
|
||||
existingQuestionId?: number
|
||||
questionText?: string
|
||||
answer: GekanatorAnswerValue
|
||||
}): Promise<{ id: number; count: number }> =>
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{ id: number; count: number }> =>
|
||||
await apiPost ('/gekanator/question_suggestions', {
|
||||
gekanator_game_id: gekanatorGameId,
|
||||
existing_question_id: existingQuestionId,
|
||||
question_text: questionText,
|
||||
answer })
|
||||
answer }, { locale })
|
||||
|
||||
|
||||
export const saveGekanatorExtraQuestionAnswers = async ({
|
||||
export const saveGekanatorExtraQuestionAnswers = async (
|
||||
{
|
||||
gameId,
|
||||
answers,
|
||||
}: {
|
||||
gameId: number
|
||||
answers: { questionId: number; answer: GekanatorAnswerValue }[]
|
||||
}) =>
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
) =>
|
||||
await apiPost (`/gekanator/games/${ gameId }/extra_question_answers`, {
|
||||
answers: answers.map (item => ({
|
||||
question_id: item.questionId,
|
||||
answer: item.answer })) })
|
||||
answer: item.answer })) }, { locale })
|
||||
|
||||
+13
-2
@@ -13,12 +13,14 @@ export type HelpEditionText = {
|
||||
|
||||
export type HelpSearchEntry = HelpEditionText & {
|
||||
identifier: string
|
||||
language?: string
|
||||
edition: string }
|
||||
|
||||
export type HelpEditionContent = HelpEditionText & { content: ComponentType }
|
||||
|
||||
export type HelpEditionSource = HelpEditionText & { source: string }
|
||||
export type HelpChange = Pick<HelpSearchEntry, 'identifier' | 'edition' | 'title'> & {
|
||||
language?: string
|
||||
pred: string | null
|
||||
change: 'new' | 'update'
|
||||
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 (
|
||||
identifier: string,
|
||||
language: string,
|
||||
@@ -144,7 +155,7 @@ export const getHelpSearchEntries = async (
|
||||
language: string,
|
||||
): Promise<HelpSearchEntry[]> => {
|
||||
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[]> => {
|
||||
const { default: changes } = await import ('virtual:help/changes')
|
||||
return changes[language] ?? []
|
||||
return (changes[language] ?? []).map (entry => ({ ...entry, language }))
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { unlocalisedPath } from '@/lib/locales'
|
||||
|
||||
export type ShortcutScope =
|
||||
| 'global'
|
||||
| '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'))
|
||||
return 'postSearch'
|
||||
if (pathname.startsWith ('/posts'))
|
||||
|
||||
@@ -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 ()
|
||||
})
|
||||
})
|
||||
@@ -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 type { LocaleCode } from '@/lib/locales'
|
||||
import type { Material,
|
||||
MaterialIndexResponse,
|
||||
MaterialVersion,
|
||||
@@ -36,8 +37,10 @@ export const fetchMaterials = async (
|
||||
updatedFrom, updatedTo, sort, direction, page,
|
||||
tagId, includeDescendants, groupBy,
|
||||
limit }: FetchMaterialsParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
|
||||
): Promise<MaterialIndexResponse> =>
|
||||
await apiGet ('/materials', { params: {
|
||||
await apiGet ('/materials', { locale, params: {
|
||||
...(q && { q }),
|
||||
tag_state: tagState,
|
||||
media_kind: mediaKind,
|
||||
@@ -54,10 +57,13 @@ export const fetchMaterials = async (
|
||||
limit} })
|
||||
|
||||
|
||||
export const fetchMaterial = async (id: string): Promise<Material | null> => {
|
||||
export const fetchMaterial = async (
|
||||
id: string,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<Material | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/materials/${ id }`)
|
||||
return await apiGet (`/materials/${ id }`, { locale })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
@@ -75,8 +81,10 @@ export const fetchMaterialChanges = async (
|
||||
eventType?: string
|
||||
page: 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 }),
|
||||
...(tag && { tag }),
|
||||
...(eventType && { event_type: eventType }),
|
||||
@@ -85,19 +93,23 @@ export const fetchMaterialChanges = async (
|
||||
|
||||
|
||||
export const fetchMaterialTagTree = async (
|
||||
{ parentId, materialFilter }: FetchMaterialTreeParams): Promise<MaterialSidebarTag[]> =>
|
||||
await apiGet ('/tags/with-depth', { params: {
|
||||
{ parentId, materialFilter }: FetchMaterialTreeParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<MaterialSidebarTag[]> =>
|
||||
await apiGet ('/tags/with-depth', { locale, params: {
|
||||
...(parentId != null && { parent: String (parentId) }),
|
||||
material_filter: materialFilter} })
|
||||
|
||||
|
||||
export const fetchMaterialTagByName = async (
|
||||
name: string,
|
||||
materialFilter: MaterialFilter): Promise<MaterialTagTree | null> => {
|
||||
materialFilter: MaterialFilter,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<MaterialTagTree | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }/materials`,
|
||||
{ params: { material_filter: materialFilter } })
|
||||
{ locale, params: { material_filter: materialFilter } })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
@@ -108,19 +120,26 @@ export const fetchMaterialTagByName = async (
|
||||
}
|
||||
|
||||
|
||||
export const createMaterial = async (formData: FormData): Promise<Material> =>
|
||||
await apiPost ('/materials', formData)
|
||||
export const createMaterial = async (
|
||||
formData: FormData,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<Material> =>
|
||||
await apiPost ('/materials', formData, { locale })
|
||||
|
||||
|
||||
export const updateMaterial = async (
|
||||
id: string,
|
||||
formData: FormData): Promise<Material> =>
|
||||
await apiPut (`/materials/${ id }`, formData)
|
||||
formData: FormData,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<Material> =>
|
||||
await apiPut (`/materials/${ id }`, formData, { locale })
|
||||
|
||||
|
||||
export const fetchMaterialSyncSuppressions =
|
||||
async (): Promise<MaterialSyncSuppressionResponse> =>
|
||||
await apiGet ('/materials/suppressions')
|
||||
async (
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<MaterialSyncSuppressionResponse> =>
|
||||
await apiGet ('/materials/suppressions', { locale })
|
||||
|
||||
|
||||
export const createMaterialSyncSuppression = async (
|
||||
@@ -130,10 +149,12 @@ export const createMaterialSyncSuppression = async (
|
||||
drivePath?: string
|
||||
driveFileId?: string
|
||||
reason: string
|
||||
}): Promise<MaterialSyncSuppression> =>
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<MaterialSyncSuppression> =>
|
||||
await apiPost ('/materials/suppressions', {
|
||||
source_kind: payload.sourceKind,
|
||||
source_uri: payload.sourceUri,
|
||||
drive_path: payload.drivePath,
|
||||
drive_file_id: payload.driveFileId,
|
||||
reason: payload.reason})
|
||||
reason: payload.reason}, { locale })
|
||||
|
||||
@@ -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 type { LocaleCode } from '@/lib/locales'
|
||||
import type { FetchPostsParams, Post, PostVersion } from '@/types'
|
||||
|
||||
|
||||
export const fetchPosts = async (
|
||||
{ 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[]
|
||||
count: number }> =>
|
||||
await apiGet ('/posts', { params: {
|
||||
await apiGet ('/posts', { locale, params: {
|
||||
...(url && { url }),
|
||||
...(title && { title }),
|
||||
...(tags && { tags }),
|
||||
@@ -24,7 +27,10 @@ export const fetchPosts = async (
|
||||
...(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 (
|
||||
@@ -33,10 +39,12 @@ export const fetchPostChanges = async (
|
||||
tag?: string
|
||||
externalTag?: string
|
||||
page: number
|
||||
limit: number }): Promise<{
|
||||
limit: number },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{
|
||||
versions: PostVersion[]
|
||||
count: number }> =>
|
||||
await apiGet ('/posts/versions', { params: { ...(post && { post }),
|
||||
await apiGet ('/posts/versions', { locale, params: { ...(post && { post }),
|
||||
...(tag && { tag }),
|
||||
...(externalTag && {
|
||||
external_tag: externalTag }),
|
||||
@@ -54,7 +62,9 @@ export const updatePost = async (
|
||||
{ baseVersionNo, force, merge }: {
|
||||
baseVersionNo?: number
|
||||
force?: boolean
|
||||
merge?: boolean }) =>
|
||||
merge?: boolean },
|
||||
locale: LocaleCode = 'ja',
|
||||
) =>
|
||||
await apiPut<Post> (
|
||||
`/posts/${ post.id }`,
|
||||
{ title: post.title,
|
||||
@@ -63,7 +73,7 @@ export const updatePost = async (
|
||||
parent_post_ids: post.parentPostIds,
|
||||
original_created_from: post.originalCreatedFrom,
|
||||
original_created_before: post.originalCreatedBefore },
|
||||
{ params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
|
||||
{ locale, params: { ...(baseVersionNo && { base_version_no: String (baseVersionNo) }),
|
||||
force: force ? '1' : '0',
|
||||
merge: merge ? '1' : '0' } })
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import { getHelpArticle,
|
||||
getHelpChanges,
|
||||
getHelpDiff,
|
||||
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 { helpKeys, postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys'
|
||||
import { fetchNicoTags, fetchTagByName, fetchTag, fetchTagChanges, fetchTags } from '@/lib/tags'
|
||||
@@ -13,9 +15,10 @@ import { fetchWikiPage,
|
||||
fetchWikiPageByTitle,
|
||||
fetchWikiPages } from '@/lib/wiki'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
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 mWiki = match<{ title: string }> ('/wiki/:title')
|
||||
@@ -28,25 +31,27 @@ const boolFromQuery = (value: string | null): boolean =>
|
||||
['1', 'true', 'on', 'yes', ''].includes ((value ?? '').toLowerCase ())
|
||||
|
||||
|
||||
const prefetchHelpIndex: Prefetcher = async qc => {
|
||||
const prefetchHelpIndex: Prefetcher = async (qc, url, locale) => {
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: helpKeys.index ('ja'),
|
||||
queryFn: () => getHelpSearchEntries ('ja'),
|
||||
queryKey: helpKeys.index (localeLanguage (locale)),
|
||||
queryFn: () => getHelpSearchEntries (localeLanguage (locale)),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpShow: Prefetcher = async (qc, url) => {
|
||||
const prefetchHelpShow: Prefetcher = async (qc, url, locale) => {
|
||||
const matched = mHelp (url.pathname)
|
||||
if (!(matched))
|
||||
return
|
||||
|
||||
const { identifier } = matched.params
|
||||
const language = 'ja'
|
||||
const article = await qc.fetchQuery ({
|
||||
queryKey: helpKeys.article (identifier),
|
||||
queryFn: () => getHelpArticle (identifier),
|
||||
staleTime: Infinity })
|
||||
const language = resolveHelpLanguage (article, localeLanguage (locale))
|
||||
if (language == null)
|
||||
return
|
||||
const edition = url.searchParams.get ('edition') ?? article?.languages[language]?.currentEdition
|
||||
if (edition == null)
|
||||
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 ({
|
||||
queryKey: helpKeys.changes ('ja'),
|
||||
queryFn: () => getHelpChanges ('ja'),
|
||||
queryKey: helpKeys.changes (localeLanguage (locale)),
|
||||
queryFn: () => getHelpChanges (localeLanguage (locale)),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchHelpDiff: Prefetcher = async (qc, url) => {
|
||||
const prefetchHelpDiff: Prefetcher = async (qc, url, locale) => {
|
||||
const matched = mHelpDiff (url.pathname)
|
||||
const fromValues = url.searchParams.getAll ('from')
|
||||
const toValues = url.searchParams.getAll ('to')
|
||||
@@ -76,23 +81,30 @@ const prefetchHelpDiff: Prefetcher = async (qc, url) => {
|
||||
return
|
||||
|
||||
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 ({
|
||||
queryKey: helpKeys.diff (identifier, 'ja', from, to),
|
||||
queryFn: () => getHelpDiff (identifier, 'ja', from, to),
|
||||
queryKey: helpKeys.diff (identifier, language, from, to),
|
||||
queryFn: () => getHelpDiff (identifier, language, from, to),
|
||||
staleTime: Infinity })
|
||||
}
|
||||
|
||||
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => {
|
||||
const prefetchWikiPagesIndex: Prefetcher = async (qc, url, locale) => {
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: wikiKeys.index ({ title }),
|
||||
queryFn: () => fetchWikiPages ({ title }) })
|
||||
queryKey: wikiKeys.index ({ title }, locale),
|
||||
queryFn: () => fetchWikiPages ({ title }, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
|
||||
const prefetchWikiPageShow: Prefetcher = async (qc, url, locale) => {
|
||||
const m = mWiki (url.pathname)
|
||||
if (!(m))
|
||||
return
|
||||
@@ -101,29 +113,29 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
|
||||
const version = url.searchParams.get ('version') || undefined
|
||||
|
||||
const wikiPage = await qc.fetchQuery ({
|
||||
queryKey: wikiKeys.show (title, { version }),
|
||||
queryFn: () => fetchWikiPageByTitle (title, { version }) })
|
||||
queryKey: wikiKeys.show (title, { version }, locale),
|
||||
queryFn: () => fetchWikiPageByTitle (title, { version }, locale) })
|
||||
|
||||
if (wikiPage)
|
||||
{
|
||||
const effectiveId = String (wikiPage.id ?? '')
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: wikiKeys.show (effectiveId, { }),
|
||||
queryFn: () => fetchWikiPage (effectiveId, { } ) })
|
||||
queryKey: wikiKeys.show (effectiveId, { }, locale),
|
||||
queryFn: () => fetchWikiPage (effectiveId, { }, locale ) })
|
||||
|
||||
if (wikiPage.body)
|
||||
{
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: wikiKeys.index ({ }),
|
||||
queryFn: () => fetchWikiPages ({ }) })
|
||||
queryKey: wikiKeys.index ({ }, locale),
|
||||
queryFn: () => fetchWikiPages ({ }, locale) })
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveTitle = wikiPage?.title ?? title
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.show (effectiveTitle),
|
||||
queryFn: () => fetchTagByName (effectiveTitle) })
|
||||
queryKey: tagsKeys.show (effectiveTitle, locale),
|
||||
queryFn: () => fetchTagByName (effectiveTitle, locale) })
|
||||
|
||||
if (version)
|
||||
return
|
||||
@@ -143,12 +155,12 @@ const prefetchWikiPageShow: Prefetcher = async (qc, url) => {
|
||||
updatedTo: '',
|
||||
order: 'original_created_at:desc' } as const
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: postsKeys.index (p),
|
||||
queryFn: () => fetchPosts (p) })
|
||||
queryKey: postsKeys.index (p, locale),
|
||||
queryFn: () => fetchPosts (p, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchPostsIndex: Prefetcher = async (qc, url) => {
|
||||
const prefetchPostsIndex: Prefetcher = async (qc, url, locale) => {
|
||||
const tags = url.searchParams.get ('tags') ?? ''
|
||||
const qURL = url.searchParams.get ('url') ?? ''
|
||||
const title = url.searchParams.get ('title') ?? ''
|
||||
@@ -169,12 +181,12 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => {
|
||||
updatedFrom, updatedTo, order }
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: postsKeys.index (keys),
|
||||
queryFn: () => fetchPosts (keys) })
|
||||
queryKey: postsKeys.index (keys, locale),
|
||||
queryFn: () => fetchPosts (keys, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchPostShow: Prefetcher = async (qc, url) => {
|
||||
const prefetchPostShow: Prefetcher = async (qc, url, locale) => {
|
||||
const m = mPost (url.pathname)
|
||||
if (!(m))
|
||||
return
|
||||
@@ -182,12 +194,12 @@ const prefetchPostShow: Prefetcher = async (qc, url) => {
|
||||
const { id } = m.params
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: postsKeys.show (id),
|
||||
queryFn: () => fetchPost (id) })
|
||||
queryKey: postsKeys.show (id, locale),
|
||||
queryFn: () => fetchPost (id, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchPostChanges: Prefetcher = async (qc, url) => {
|
||||
const prefetchPostChanges: Prefetcher = async (qc, url, locale) => {
|
||||
const id = url.searchParams.get ('id')
|
||||
const tag = url.searchParams.get ('tag')
|
||||
const externalTag = url.searchParams.get ('external_tag')
|
||||
@@ -197,23 +209,23 @@ const prefetchPostChanges: Prefetcher = async (qc, url) => {
|
||||
if (tag)
|
||||
{
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.show (tag),
|
||||
queryFn: () => fetchTag (tag) })
|
||||
queryKey: tagsKeys.show (tag, locale),
|
||||
queryFn: () => fetchTag (tag, locale) })
|
||||
}
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: postsKeys.changes ({ ...(id && { id }),
|
||||
...(tag && { tag }),
|
||||
...(externalTag && { externalTag }),
|
||||
page, limit }),
|
||||
page, limit }, locale),
|
||||
queryFn: () => fetchPostChanges ({ ...(id && { id }),
|
||||
...(tag && { tag }),
|
||||
...(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 post = postRaw ? Number (postRaw) : null
|
||||
const name = url.searchParams.get ('name') ?? ''
|
||||
@@ -237,12 +249,12 @@ const prefetchTagsIndex: Prefetcher = async (qc, url) => {
|
||||
updatedFrom, updatedTo, deprecated, page, limit, order }
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.index (keys),
|
||||
queryFn: () => fetchTags (keys) })
|
||||
queryKey: tagsKeys.index (keys, locale),
|
||||
queryFn: () => fetchTags (keys, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
|
||||
const prefetchNicoTagsIndex: Prefetcher = async (qc, url, locale) => {
|
||||
const keys = {
|
||||
name: url.searchParams.get ('name') ?? '',
|
||||
linkedTag: url.searchParams.get ('linked_tag') ?? '',
|
||||
@@ -255,12 +267,12 @@ const prefetchNicoTagsIndex: Prefetcher = async (qc, url) => {
|
||||
| 'updated_at:asc' | 'updated_at:desc' }
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.nicoIndex (keys),
|
||||
queryFn: () => fetchNicoTags (keys) })
|
||||
queryKey: tagsKeys.nicoIndex (keys, locale),
|
||||
queryFn: () => fetchNicoTags (keys, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchTagShow: Prefetcher = async (qc, url) => {
|
||||
const prefetchTagShow: Prefetcher = async (qc, url, locale) => {
|
||||
const m = mTag (url.pathname)
|
||||
if (!(m))
|
||||
return
|
||||
@@ -268,19 +280,19 @@ const prefetchTagShow: Prefetcher = async (qc, url) => {
|
||||
const { id } = m.params
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.show (id),
|
||||
queryFn: () => fetchTag (id) })
|
||||
queryKey: tagsKeys.show (id, locale),
|
||||
queryFn: () => fetchTag (id, locale) })
|
||||
}
|
||||
|
||||
|
||||
const prefetchTagChanges: Prefetcher = async (qc, url) => {
|
||||
const prefetchTagChanges: Prefetcher = async (qc, url, locale) => {
|
||||
const id = url.searchParams.get ('id')
|
||||
const page = Number (url.searchParams.get ('page') || 1)
|
||||
const limit = Number (url.searchParams.get ('limit') || 20)
|
||||
|
||||
await qc.prefetchQuery ({
|
||||
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }),
|
||||
queryFn: () => fetchTagChanges ({ ...(id && { id }), page, limit }) })
|
||||
queryKey: tagsKeys.changes ({ ...(id && { id }), page, limit }, locale),
|
||||
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> => {
|
||||
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))
|
||||
if (!(r))
|
||||
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 {
|
||||
FetchNicoTagsParams,
|
||||
FetchMaterialsParams,
|
||||
@@ -6,61 +7,131 @@ import type {
|
||||
MaterialFilter,
|
||||
} from '@/types'
|
||||
|
||||
export const localesKeys = {
|
||||
root: ['locales'] as const,
|
||||
index: ['locales', 'index'] as const }
|
||||
|
||||
export const postsKeys = {
|
||||
root: ['posts'] as const,
|
||||
index: (p: FetchPostsParams) => ['posts', 'index', p] as const,
|
||||
show: (id: string) => ['posts', id] as const,
|
||||
related: (id: string) => ['related', id] as const,
|
||||
changes: (p: { post?: string
|
||||
index: (
|
||||
p: FetchPostsParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['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
|
||||
externalTag?: string
|
||||
page: number
|
||||
limit: number }) =>
|
||||
['posts', 'changes', p] as const }
|
||||
limit: number },
|
||||
locale: LocaleCode = 'ja',
|
||||
) =>
|
||||
['posts', locale, 'changes', p] as const }
|
||||
|
||||
export const gekanatorKeys = {
|
||||
root: ['gekanator'] as const,
|
||||
posts: () => ['gekanator', 'posts'] as const,
|
||||
questions: () => ['gekanator', 'questions'] as const,
|
||||
extraQuestions: (gameId: number, nonce: string) =>
|
||||
['gekanator', 'games', gameId, 'extra-questions', nonce] as const }
|
||||
posts: (
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['gekanator', locale, 'posts'] 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 = {
|
||||
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,
|
||||
nicoIndex: (p: FetchNicoTagsParams) => ['tags', 'nico', 'index', p] as const,
|
||||
externalShow: (id: string) => ['tags', 'nico', id] as const,
|
||||
show: (name: string) => ['tags', name] as const,
|
||||
changes: (p: { id?: string; page: number; limit: number }) =>
|
||||
['tags', 'changes', p] as const,
|
||||
deerjikists: (id: string) => ['tags', 'deerjikists', id] as const }
|
||||
nicoIndex: (
|
||||
p: FetchNicoTagsParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['tags', locale, 'nico', 'index', p] as const,
|
||||
externalShow: (
|
||||
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 = {
|
||||
root: ['materials'] as const,
|
||||
index: (p: FetchMaterialsParams) => ['materials', 'index', p] as const,
|
||||
changes: (p: {
|
||||
index: (
|
||||
p: FetchMaterialsParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['materials', locale, 'index', p] as const,
|
||||
changes: (
|
||||
p: {
|
||||
materialId?: string
|
||||
tag?: string
|
||||
eventType?: string
|
||||
page: number
|
||||
limit: number
|
||||
}) => ['materials', 'changes', p] as const,
|
||||
byTagName: (name: string, materialFilter: MaterialFilter) =>
|
||||
['materials', 'tag', name, materialFilter] as const,
|
||||
show: (id: string) => ['materials', id] as const,
|
||||
suppressions: () => ['materials', 'suppressions'] as const,
|
||||
tree: (p: {
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['materials', locale, 'changes', p] as const,
|
||||
byTagName: (
|
||||
name: string,
|
||||
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
|
||||
materialFilter: MaterialFilter
|
||||
}) => ['materials', 'tree', p] as const,
|
||||
unclassified: (p: { page?: number; limit?: number } = { }) =>
|
||||
['materials', 'unclassified', p] as const}
|
||||
},
|
||||
locale: LocaleCode = 'ja',
|
||||
) => ['materials', locale, 'tree', p] as const,
|
||||
unclassified: (
|
||||
p: { page?: number; limit?: number } = { },
|
||||
locale: LocaleCode = 'ja',
|
||||
) =>
|
||||
['materials', locale, 'unclassified', p] as const}
|
||||
|
||||
export const wikiKeys = {
|
||||
root: ['wiki'] as const,
|
||||
index: (p: { title?: string }) => ['wiki', 'index', p] as const,
|
||||
show: (title: string, p: { version?: string }) => ['wiki', title, p] as const }
|
||||
index: (
|
||||
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 = {
|
||||
root: ['help'] as const,
|
||||
|
||||
@@ -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 type { LocaleCode } from '@/lib/locales'
|
||||
import type { Deerjikist,
|
||||
FetchNicoTagsParams,
|
||||
FetchTagsParams,
|
||||
@@ -13,9 +14,11 @@ import type { Deerjikist,
|
||||
export const fetchTags = async (
|
||||
{ post, name, category, postCountGTE, postCountLTE, createdFrom, createdTo,
|
||||
updatedFrom, updatedTo, deprecated,
|
||||
page, limit, order }: FetchTagsParams): Promise<{ tags: Tag[]
|
||||
page, limit, order }: FetchTagsParams,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{ tags: Tag[]
|
||||
count: number }> =>
|
||||
await apiGet ('/tags', { params: {
|
||||
await apiGet ('/tags', { locale, params: {
|
||||
...(post != null && { post }),
|
||||
...(name && { name }),
|
||||
...(category && { category }),
|
||||
@@ -32,9 +35,11 @@ export const fetchTags = 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 }> =>
|
||||
await apiGet ('/tags/nico', { params: {
|
||||
await apiGet ('/tags/nico', { locale, params: {
|
||||
page,
|
||||
limit,
|
||||
name,
|
||||
@@ -43,10 +48,13 @@ export const fetchNicoTags = async (
|
||||
order } })
|
||||
|
||||
|
||||
export const fetchTag = async (id: string): Promise<Tag | null> => {
|
||||
export const fetchTag = async (
|
||||
id: string,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<Tag | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/tags/${ id }`)
|
||||
return await apiGet (`/tags/${ id }`, { locale })
|
||||
}
|
||||
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
|
||||
{
|
||||
return await apiGet (`/tags/nico/${ id }`)
|
||||
return await apiGet (`/tags/nico/${ id }`, { locale })
|
||||
}
|
||||
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
|
||||
{
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`)
|
||||
return await apiGet (`/tags/name/${ encodeURIComponent (name) }`, { locale })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
@@ -84,12 +98,16 @@ export const fetchTagChanges = async (
|
||||
{ id, page, limit }: {
|
||||
id?: string
|
||||
page: number
|
||||
limit: number }): Promise<{
|
||||
limit: number },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{
|
||||
versions: TagVersion[]
|
||||
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 (
|
||||
id: string): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
|
||||
await apiGet (`/tags/${ id }/deerjikists`)
|
||||
id: string,
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<{ tag: Tag; deerjikists: Deerjikist[] }> =>
|
||||
await apiGet (`/tags/${ id }/deerjikists`, { locale })
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} 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 {
|
||||
DEFAULT_KEY_BINDINGS,
|
||||
@@ -95,7 +96,7 @@ const focusSearchTarget = (): void => {
|
||||
|
||||
export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
|
||||
const location = useLocation ()
|
||||
const navigate = useNavigate ()
|
||||
const navigate = useLocaleNavigate ()
|
||||
|
||||
const [keyboardSettings, setKeyboardSettingsState] =
|
||||
useState<ClientKeyboardSettings> (() => getClientKeyboardSettings ())
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { twMerge } from 'tailwind-merge'
|
||||
|
||||
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
|
||||
|
||||
@@ -25,6 +27,24 @@ export const dateString = (
|
||||
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 = (
|
||||
f: string | Date | null,
|
||||
b: string | Date | null): string => {
|
||||
|
||||
+13
-6
@@ -2,26 +2,33 @@ import { isAxiosError } from 'axios'
|
||||
|
||||
import { apiGet } from '@/lib/api'
|
||||
|
||||
import type { LocaleCode } from '@/lib/locales'
|
||||
import type { WikiPage } from '@/types'
|
||||
|
||||
|
||||
export const fetchWikiPages = async (
|
||||
{ title }: { title?: string }): Promise<WikiPage[]> =>
|
||||
await apiGet ('/wiki', { params: { title } })
|
||||
{ title }: { title?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPage[]> =>
|
||||
await apiGet ('/wiki', { locale, params: { title } })
|
||||
|
||||
|
||||
export const fetchWikiPage = async (
|
||||
id: string,
|
||||
{ version }: { version?: string }): Promise<WikiPage> =>
|
||||
await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } })
|
||||
{ version }: { version?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPage> =>
|
||||
await apiGet (`/wiki/${ id }`, { locale, params: version ? { version } : { } })
|
||||
|
||||
|
||||
export const fetchWikiPageByTitle = async (
|
||||
title: string,
|
||||
{ version }: { version?: string }): Promise<WikiPage | null> => {
|
||||
{ version }: { version?: string },
|
||||
locale: LocaleCode = 'ja',
|
||||
): Promise<WikiPage | null> => {
|
||||
try
|
||||
{
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } })
|
||||
return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { locale, params: { version } })
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
|
||||
@@ -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 ソモ アン。" } }
|
||||
@@ -0,0 +1,17 @@
|
||||
{ "403": {
|
||||
"message": "エエイワンケ エアイカㇷ゚",
|
||||
"right": "ホㇱピ ヤン!",
|
||||
"left": "ホㇱピ ヤン!" },
|
||||
"404": {
|
||||
"message": "タン ソㇱ イサㇺ",
|
||||
"right": "ホㇱピ ヤン!",
|
||||
"left": "ホㇱピ ヤン!" },
|
||||
"500": {
|
||||
"message": "サーバー オㇿ ハイタ アン",
|
||||
"right": "カンリニン ウン",
|
||||
"left": "エイェ ヤン!" },
|
||||
"503": {
|
||||
"message": "サーバー アㇱカイ ソモ キ",
|
||||
"right": "カンリニン ウン",
|
||||
"left": "エイェ ヤン!" },
|
||||
"imageAlt": "キタ イクヨ" }
|
||||
@@ -0,0 +1,7 @@
|
||||
{ "title": "ヤイカオパㇱ",
|
||||
"search": "ヤイカオパㇱ イフナラ",
|
||||
"history": "ヤイカオパㇱ アシㇼ カㇻ フㇱコアンペ",
|
||||
"effectiveSince": "アㇱカイ ヘㇻパ ト",
|
||||
"empty": "イフナラ ワ ネ ペ イサㇺ。",
|
||||
"diffTitle": "ヤイカオパㇱ ウシンナイペ:{{title}}",
|
||||
"articleTitle": "{{title}} ヤイカオパㇱ" }
|
||||
@@ -0,0 +1,7 @@
|
||||
{ "status": {
|
||||
"ready": "アオ エアㇱカイ",
|
||||
"error": "アオ エアイカㇷ゚",
|
||||
"warning": "イパㇰカㇱヌ",
|
||||
"skipped": "スキップ",
|
||||
"created": "アオ オケレ",
|
||||
"failed": "アオ カ エアイカㇷ゚" } }
|
||||
@@ -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": "モㇱマ アン ペ »" }
|
||||
@@ -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": "エヌイェㇷ゚ ホㇱキ シㇼ ウン ホㇱピ ヤ?" } }
|
||||
@@ -0,0 +1,11 @@
|
||||
{ "title": "タㇰ",
|
||||
"name": "レ",
|
||||
"postCount": "ミンタㇻ アオ ペ チピㇱキㇷ゚",
|
||||
"firstRecordedAt": "ホㇱキ アヌイェ ト ヘㇺ トキ",
|
||||
"definitionUpdatedAt": "イタㇰ アシㇼ アカㇻ ト ヘㇺ トキ",
|
||||
"aliases": "モㇱマ レ",
|
||||
"parents": "サパ タㇰ",
|
||||
"firstRecordedColumn": "ホㇱキ アヌイェ ト",
|
||||
"updatedAt": "アシㇼ アカㇻ ト",
|
||||
"history": "フㇱコアンペ",
|
||||
"empty": "イフナラ ワ ネ ペ イサㇺ" }
|
||||
@@ -0,0 +1,8 @@
|
||||
{ "newPage": "アシㇼ ウィキ ソㇱ",
|
||||
"editPage": "ウィキ ソㇱ イヌイェㇷ゚",
|
||||
"history": "ウィキ アシㇼ カㇻ フㇱコアンペ",
|
||||
"historyHeading": "ウィキ フㇱコアンペ",
|
||||
"change": "アシㇼ カㇻ",
|
||||
"dateTime": "ト ヘㇺ トキ",
|
||||
"diffTitle": "ウィキ ウシンナイペ:{{title}}",
|
||||
"missingPage": "タン ソㇱ イサㇺ。[アシㇼ カㇻ ヤン]({{url}})。" }
|
||||
@@ -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 が存在しません."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"403": { "message": "権限ないよ(笑)", "right": "帰れ!", "left": "帰れ!" },
|
||||
"404": { "message": "ページないよ(笑)", "right": "帰れ!", "left": "帰れ!" },
|
||||
"500": { "message": "鯖でエラー出たって(嘲笑)", "right": "管理人に", "left": "聯絡を!" },
|
||||
"503": { "message": "鯖死んでるよ(泣)", "right": "管理人に", "left": "聯絡を!" },
|
||||
"imageAlt": "逃げたギター"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "ヘルプ", "search": "ヘルプ検索", "history": "ヘルプ改定履歴",
|
||||
"effectiveSince": "有効開始年月日", "empty": "該当する記事がありません.",
|
||||
"diffTitle": "ヘルプ 差分: {{title}}", "articleTitle": "{{title}} ヘルプ"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"status": {
|
||||
"ready": "登録可能", "error": "登録不可", "warning": "警告",
|
||||
"skipped": "スキップ", "created": "登録済み", "failed": "登録失敗"
|
||||
}
|
||||
}
|
||||
変更されたファイルが多すぎるため、一部のファイルは表示されません さらに表示
新しいイシューから参照
ユーザーをブロックする