コミットを比較

..
2 コミット
作成者 SHA1 メッセージ 日付
Codex Night Worker d6fc57a00e AI: address issue #479 2026-11-16 00:18:56 +09:00
みてるぞとCodex Night Worker dda3d87073 外部タグの履歴管理方式を変更 (#445) (#480)
Co-authored-by: Codex Night Worker <codex-night@localhost>
Reviewed-on: #480
Co-authored-by: miteruzo <miteruzo@naver.com>
2026-11-15 16:18:27 +09:00
81個のファイルの変更、4028行の追加、1688行の削除
-35
ファイルの表示
@@ -1,35 +0,0 @@
## 背景
なぜ必要か。
## 対象範囲
- backend:
- frontend:
- docs:
- migration:
## やること
- [ ]
## 受け入れ条件
- [ ]
## 実行すべき確認
- [ ] `cd backend && bundle exec rspec`
- [ ] `cd frontend && npm run build`
- [ ] `cd frontend && npm run lint`
## 禁止事項
- unrelated refactor はしない
- 既存 API response shape を壊さない
- 認証・認可・BAN を弱めない
## Codex への指示
この issue を読んで実装してください。
不明点があれば、実装前に調査結果と選択肢を提示してください。
+1 -1
ファイルの表示
@@ -424,7 +424,7 @@ races according to the target constraint's business meaning.
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
user, `:not_found` for missing records, and `:unprocessable_content` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
+1 -1
ファイルの表示
@@ -95,7 +95,7 @@ class ApplicationController < ActionController::API
render_validation_error base: ['すでに存在してゐます.']
end
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_entity
def render_validation_error record = nil, fields: { }, base: [], status: :unprocessable_content
errors = { }
if record
+3 -3
ファイルの表示
@@ -18,7 +18,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
status: :unprocessable_content
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -63,7 +63,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
status: :unprocessable_content
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -107,7 +107,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
status: :unprocessable_content
rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
+1 -1
ファイルの表示
@@ -24,7 +24,7 @@ class CountsTablesController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity
status: :unprocessable_content
rescue CountsPivotTotals::TimedOut
Rails.logger.warn('counts_query_timeout: pivot_totals')
render json: { error: 'counts_query_timeout', stage: 'totals',
+2 -2
ファイルの表示
@@ -15,7 +15,7 @@ class GekanatorGamesController < ApplicationController
answers:)
if game.invalid?
render json: { errors: game.errors.full_messages }, status: :unprocessable_entity
render json: { errors: game.errors.full_messages }, status: :unprocessable_content
return
end
@@ -31,7 +31,7 @@ class GekanatorGamesController < ApplicationController
learned_example_count:
}, status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content
end
def extra_questions
+1 -1
ファイルの表示
@@ -536,7 +536,7 @@ class MaterialsController < ApplicationController
errors: { },
base_errors: ['ZIP export に必要な素材ファイルが欠損しています.'],
missing_files: },
status: :unprocessable_entity
status: :unprocessable_content
end
def build_uploaded_material_blob! file, file_sha256
+1 -10
ファイルの表示
@@ -100,18 +100,9 @@ class PostVersionsController < ApplicationController
def external_tag_names_for snapshots
ids = snapshots.flatten.filter_map { _1['external_tag_id'] }.uniq
names = ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
ExternalTag.where(id: ids).pluck(:id, :platform, :name).to_h { |id, platform, name|
[id, "#{ platform }:#{ name }"]
}
missing_ids = ids - names.keys
HistoryAccess.visible(NicoTagVersion.all)
.where(tag_id: missing_ids)
.order(:tag_id, version_no: :desc)
.pluck(:tag_id, :name)
.each { |id, name| names[id] ||= name }
names
end
def snapshot_tag_literals snapshots, tag_names, external_tag_names
+2 -2
ファイルの表示
@@ -429,7 +429,7 @@ class PostsController < ApplicationController
filters = literals.map do |literal|
internal, = source_ids.fetch(literal[:name])
name = internal.empty? ? literal[:name] : names[internal.first]
return head :unprocessable_entity unless name && internal.length <= 1
return head :unprocessable_content unless name && internal.length <= 1
literal[:negative] ? "not:#{ name }" : name
end
@@ -438,7 +438,7 @@ class PostsController < ApplicationController
literals.zip(translated).each do |literal, translation|
unless (source_ids.fetch(literal[:name]).map(&:sort) ==
target_ids.fetch(translation[:name]).map(&:sort))
return head :unprocessable_entity
return head :unprocessable_content
end
end
index = 0
+2 -2
ファイルの表示
@@ -55,7 +55,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
return head :unprocessable_content if requested_post_id <= 0
skipped = false
conflicted = false
@@ -95,7 +95,7 @@ class TheatresController < ApplicationController
theatre = Theatre.find_by(id: params[:id])
return head :not_found unless theatre
requested_post_id = params[:post_id].to_i
return head :unprocessable_entity if requested_post_id <= 0
return head :unprocessable_content if requested_post_id <= 0
conflicted = false
+19 -6
ファイルの表示
@@ -1,8 +1,11 @@
class ExternalTag < ApplicationRecord
before_create { SharedResourceIdAllocator.assign!(self) }
# Must run before dependent association callbacks.
before_destroy :prevent_destroy
enum :platform, nico: 'nico'
validates :platform, presence: true, inclusion: { in: ExternalTag.platforms.keys }
validate :immutable_identity, on: :update
has_many :post_external_tags, dependent: :delete_all
has_many :posts, through: :post_external_tags
@@ -16,11 +19,21 @@ class ExternalTag < ApplicationRecord
has_many :linked_tags, through: :nico_tag_relations, source: :tag
def snapshot_linked_tag_names language_code = 'ja'
linked_tags
.joins(:tag_names)
.where(tag_names: { language_code:, primary_flg: true })
.order('tag_names.name')
.pluck('tag_names.name')
def snapshot_linked_tag_ids = linked_tags.distinct.order(:id).pluck(:id)
def delete
raise ActiveRecord::ReadOnlyRecord, '外部タグは削除できません.'
end
private
def immutable_identity
errors.add(:platform, 'は変更できません.') if will_save_change_to_platform?
errors.add(:name, 'は変更できません.') if will_save_change_to_name?
end
def prevent_destroy
errors.add(:base, '外部タグは削除できません.')
throw(:abort)
end
end
+1 -15
ファイルの表示
@@ -106,21 +106,7 @@ class HistoryAccess < ActiveSupport::CurrentAttributes
snapshot_reference(TagVersion, row),
snapshot_reference(NicoTagVersion, row, external: true)]
when 'nico_tag_versions'
# Legacy linked_tags snapshots contain names, not IDs. Match recorded names
# conservatively; never expose a private linked name through an external tag.
names = "JSON_MERGE_PRESERVE(JSON_ARRAY(private_tag.name), " +
"COALESCE(JSON_EXTRACT(private_tag.names, '$**.name'), JSON_ARRAY()))"
# DISTINCT prevents merging this independent name set into the correlated scan.
# Keep byte-distinct names even when the column collation considers them equal.
private_names = 'SELECT DISTINCT private_name.name, ' +
'CAST(private_name.name AS BINARY) AS name_bytes FROM tag_versions private_tag ' +
"JOIN (#{ discarded_keys('tag_versions', 'tag_id') }) blocked " +
'ON blocked.tag_id = private_tag.tag_id ' +
"JOIN JSON_TABLE(#{ names }, '$[*]' COLUMNS (name TEXT PATH '$')) private_name " +
"WHERE private_name.name <> ''"
["NOT EXISTS (SELECT 1 FROM (#{ private_names }) private_name WHERE " +
"LOCATE(CONCAT(' ', private_name.name, ' '), " +
"CONCAT(' ', COALESCE(#{ row }.linked_tags, ''), ' ')) > 0)"]
[list_reference('tag_versions', 'tag_id', "#{ row }.linked_tag_ids")]
when 'tag_versions'
[list_reference('tag_versions', 'tag_id', "#{ row }.parent_tag_ids")]
when 'tag_description_versions'
+6 -1
ファイルの表示
@@ -3,5 +3,10 @@ class NicoTagVersion < ApplicationRecord
belongs_to :external_tag, foreign_key: :tag_id, inverse_of: :nico_tag_versions
validates :name, presence: true
validates :event_type, inclusion: { in: ['create', 'update'] }
def name
tag = external_tag
"#{ tag.platform }:#{ tag.name }" if tag
end
end
+4 -1
ファイルの表示
@@ -139,6 +139,8 @@ class CountsVersionRepr
result = {}
table = model.table_name
comparison = @inclusive ? '<=' : '<'
name = model == NicoTagVersion ?
CountsResourceDefinition.new('nico_tags').expression('name', table) : 'name'
requests.each_slice(100) do |batch|
queries = batch.map do |id, point|
scope = HistoryAccess.visible(model.where(tag_id: id))
@@ -148,7 +150,8 @@ class CountsVersionRepr
"AND newer.version_no > #{ table }.version_no)", point)
quoted = model.connection.quote(point.iso8601(6))
names = model == TagVersion ? 'names' : 'JSON_OBJECT() AS names'
scope.select("tag_id, name, #{ names }, #{ quoted } AS reference_point").to_sql
scope.select("tag_id, #{ name } AS name, #{ names }, " +
"#{ quoted } AS reference_point").to_sql
end
next if queries.empty?
+4 -2
ファイルの表示
@@ -28,16 +28,18 @@ class CountsCandidates
[external, 'external', 'NULL', 'JSON_OBJECT()']]
sources = sources.map do |population, kind, category, names|
scope = population.scope.except(:select)
name = population.definition.expression('name')
if @params['query'].present?
pattern = "%#{ ApplicationRecord.sanitize_sql_like(@params['query']) }%"
scope = scope.where("(v.name LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
scope = scope.where("(#{ name } LIKE ? OR CAST(v.tag_id AS CHAR) = ? OR EXISTS (" +
"SELECT 1 FROM JSON_TABLE(COALESCE(JSON_EXTRACT(#{ names }, '$**.name'), " +
"JSON_ARRAY()), '$[*]' COLUMNS (name TEXT PATH '$')) candidate_name " +
'WHERE candidate_name.name LIKE ?))', pattern, @params['query'], pattern)
end
scope.select(Arel.sql(
"CONCAT('#{ kind }:', v.tag_id) AS value, v.tag_id AS id, " +
"v.name AS name, #{ names } AS names, #{ category } AS category, '#{ kind }' AS kind"))
"#{ name } AS name, #{ names } AS names, " +
"#{ category } AS category, '#{ kind }' AS kind"))
.to_sql
end
clauses = []
+1 -1
ファイルの表示
@@ -113,7 +113,7 @@ class CountsPopulation
else
raise ArgumentError unless definition.axes(history).include?(key)
scope = scope.where(definition.expression(key) => entry['value'])
scope = scope.where("#{ definition.expression(key) } <=> ?", entry['value'])
end
scope
end
+18 -3
ファイルの表示
@@ -105,12 +105,25 @@ class CountsResourceDefinition
def time_column = 'created_at'
def column(key)
raise ArgumentError unless fields.key?(key)
# Keep the V1 Counts field name at the API boundary only.
return 'linked_tag_ids' if resource == 'nico_tags' && key == 'linked_tags'
{ 'resource_id' => keys.first, 'user' => 'created_by_user_id' }.fetch(key, key)
end
def expression(key, row = 'v') = "#{ row }.#{ column(key) }"
def changes = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
def expression(key, row = 'v')
if resource == 'nico_tags' && key == 'name'
return "(SELECT CONCAT(external_tags.platform, ':', external_tags.name) " +
"FROM external_tags WHERE external_tags.id = #{ row }.tag_id)"
end
"#{ row }.#{ column(key) }"
end
def changes
columns = fields.keys - COMMON.keys - ['updated_at', 'updated_by_user_id']
resource == 'nico_tags' ? columns - ['name'] : columns
end
def axes(history)
fields.reject { |_key, type| type == :json }.keys +
(resource == 'posts' ? ['tags'] : []) + (history ? ['changed_field'] : [])
@@ -118,7 +131,9 @@ class CountsResourceDefinition
def metadata
fields.map do |key, type|
{ key:, type:, nullable: model.columns_hash.fetch(column(key)).null,
nullable = (resource == 'nico_tags' && key == 'name') ||
model.columns_hash.fetch(column(key)).null
{ key:, type:, nullable:,
operators: OPERATORS.fetch(type, []) }
end
end
+3 -1
ファイルの表示
@@ -201,6 +201,8 @@ class CountsStateQuery
end
def listing
external_name = CountsResourceDefinition.new('nico_tags')
.expression('name', 'external_version')
catalog = <<~SQL
, tag_counts AS (
SELECT membership.kind, membership.tag_id, COUNT(*) AS post_count
@@ -209,7 +211,7 @@ class CountsStateQuery
SELECT 'internal' AS kind, tag_id, name, names,
deprecated_at FROM tag_at WHERE event_type <> 'discard'
UNION ALL
SELECT tc.kind, tc.tag_id, external_version.name, JSON_OBJECT() AS names,
SELECT tc.kind, tc.tag_id, #{ external_name } AS name, JSON_OBJECT() AS names,
NULL AS deprecated_at
FROM tag_counts tc
LEFT JOIN tag_at internal_version
+2
ファイルの表示
@@ -182,6 +182,7 @@ class CountsTableQuery
order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
.limit(limit).offset((page - 1) * limit)
query = query.preload(:external_tag) if @definition.resource == 'nico_tags'
rows = query.to_a
if ['posts', 'tags'].include?(@definition.resource)
point = population.history ? population.at :
@@ -486,6 +487,7 @@ class CountsTableQuery
table = model.table_name
scope = HistoryAccess.visible(model.where(tag_id: ids)).where('created_at <= ?', point)
scope = scope.includes(:external_tag) if model == NicoTagVersion
bound = @connection.quote(point)
if population.history && @definition.resource == 'posts'
key = kind == 'internal' ? 'tag_id' : 'external_tag_id'
+2 -2
ファイルの表示
@@ -1,4 +1,5 @@
class NicoTagVersionRecorder < VersionRecorder
EVENT_TYPES = ['create', 'update'].freeze
def self.record! external_tag:, event_type:, created_by_user:
new(external_tag:, event_type:, created_by_user:).record!
end
@@ -14,8 +15,7 @@ class NicoTagVersionRecorder < VersionRecorder
def record_key = :external_tag
def snapshot_attributes
{ name: "#{ @record.platform }:#{ @record.name }",
linked_tags: @record.snapshot_linked_tag_names.join(' ') }
{ linked_tag_ids: @record.snapshot_linked_tag_ids.join(' ') }
end
def tracks_version_no_on_record? = false
+5
ファイルの表示
@@ -0,0 +1,5 @@
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
def change
add_column :nico_tag_versions, :linked_tag_ids, :text
end
end
+42
ファイルの表示
@@ -0,0 +1,42 @@
class MigrateLinkedTagsInNicoTagVersions < ActiveRecord::Migration[8.0]
class MigrationTagVersion < ActiveRecord::Base
self.table_name = 'tag_versions'
end
class MigrationNicoTagVersion < ActiveRecord::Base
self.table_name = 'nico_tag_versions'
end
def up
# 除票外部タグの恢復はないので除票履歴があれば全消しでよいはず.
execute <<~SQL
DELETE
t1
FROM
nico_tag_versions t1
INNER JOIN
nico_tag_versions t2
ON
t1.tag_id = t2.tag_id
AND t2.event_type = 'discard'
SQL
MigrationNicoTagVersion.find_each do |ntv|
linked_tag_ids = ntv.linked_tags.split.map { |name|
# 厳密には一意ぢゃないけどめんだぅだからこれでいいや.
MigrationTagVersion
.order(created_at: :desc, id: :desc)
.find_by("names ->> '$.ja.name' = ?", name)
&.tag_id
}.compact.uniq.sort.join(' ')
ntv.update_columns(linked_tag_ids:)
end
change_column_null :nico_tag_versions, :linked_tag_ids, false
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+6
ファイルの表示
@@ -0,0 +1,6 @@
class RemoveLinkedTagsFromNicoTagVersions < ActiveRecord::Migration[8.0]
def change
remove_column :nico_tag_versions, :linked_tags
remove_column :nico_tag_versions, :name
end
end
生成ファイル
+2 -3
ファイルの表示
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
ActiveRecord::Schema[8.0].define(version: 2026_11_08_020000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false
t.string "record_type", null: false
@@ -301,10 +301,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_10_15_010000) do
t.bigint "tag_id", null: false
t.integer "version_no", null: false
t.string "event_type", null: false
t.string "name", null: false
t.text "linked_tags", null: false
t.datetime "created_at", null: false
t.bigint "created_by_user_id"
t.text "linked_tag_ids", null: false
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc }
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc }
+43 -8
ファイルの表示
@@ -11,28 +11,63 @@ RSpec.describe ExternalTag, type: :model do
expect(external.reload.name).to eq('raw tag[]')
end
it 'deletes post associations without deleting posts or version history' do
it 'does not allow a persisted external tag to change its platform or name' do
external = create(:external_tag, name: 'immutable')
expect { external.update!(name: 'renamed') }
.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:name]).to include('は変更できません.')
external.reload
external.platform = nil
expect { external.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(external.errors[:platform]).to include('は変更できません.')
expect(external.reload).to have_attributes(platform: 'nico', name: 'immutable')
end
it 'rejects delete, which bypasses destroy callbacks' do
external = create(:external_tag)
expect { external.delete }.to raise_error(ActiveRecord::ReadOnlyRecord)
expect(external.reload).to be_persisted
end
it 'increments the counter through post associations without changing identity' do
external = create(:external_tag, name: 'counter')
post = create(:post)
expect {
PostExternalTag.create!(post:, external_tag: external)
}.to change { external.reload.post_count }.by(1)
expect(external.reload).to have_attributes(platform: 'nico', name: 'counter')
end
it 'does not destroy external tags or their post associations and history' do
external = create(:external_tag)
post = create(:post)
PostExternalTag.create!(post:, external_tag: external)
version = NicoTagVersionRecorder.record!(
external_tag: external, event_type: :create, created_by_user: nil)
external.destroy!
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(PostExternalTag.where(external_tag_id: external.id)).to be_empty
expect(post.reload.external_tags).to be_empty
expect(external.reload).to be_persisted
expect(PostExternalTag.exists?(post:, external_tag: external)).to be(true)
expect(post.reload.external_tags).to contain_exactly(external)
expect(version.reload.tag_id).to eq(external.id)
end
it 'deletes external links without deleting linked internal tags' do
it 'does not destroy an external tag or its links to internal tags' do
external = create(:external_tag)
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external, tag:)
relation = NicoTagRelation.create!(nico_tag: external, tag:)
external.destroy!
expect { external.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed)
expect(NicoTagRelation.where(nico_tag_id: external.id)).to be_empty
expect(external.reload).to be_persisted
expect(relation.reload).to be_persisted
expect(Tag.exists?(tag.id)).to be(true)
end
end
+3 -3
ファイルの表示
@@ -76,7 +76,7 @@ RSpec.describe 'Counts artifact policy and external references' do
end
end
it 'does not expose a private linked tag name through an external history snapshot' do
it 'does not expose a private linked tag ID through an external history snapshot' do
tag = create(:tag, primary_name: 'private-linked-name')
TagVersion.create!(tag:, version_no: 1, event_type: 'discard', name: 'private-linked-name',
category: 'general', aliases: '', parent_tag_ids: '', created_at: Time.utc(2024),
@@ -84,7 +84,7 @@ RSpec.describe 'Counts artifact policy and external references' do
auto_generated: false, aliases: [] } })
external = create(:external_tag, name: 'public-external')
row = NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:public-external', linked_tags: 'private-linked-name',
linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024))
post = create(:post)
snapshot = PostVersion.create!(post:, version_no: 1, event_type: 'create',
@@ -99,7 +99,7 @@ RSpec.describe 'Counts artifact policy and external references' do
expect(HistoryAccess.visible(NicoTagVersion.where(id: row.id))).to exist
end
public_row = NicoTagVersion.create!(external_tag: external, version_no: 2, event_type: 'update',
name: 'nico:public-external', linked_tags: 'private-linked-name-extra',
linked_tag_ids: '',
created_at: Time.utc(2024, 7))
HistoryAccess.set(user: nil) do
expect(HistoryAccess.visible(NicoTagVersion.where(id: public_row.id))).to exist
+3 -4
ファイルの表示
@@ -10,23 +10,22 @@ RSpec.describe 'Shared Tag and ExternalTag IDs', type: :model do
expect(next_tag.id).to be > external.id
end
it 'does not reuse physically deleted IDs in either table' do
it 'does not reuse IDs after internal deletion while external tags remain' do
tag = create(:tag)
external = create(:external_tag)
TagName.where(tag_id: tag.id).delete_all
tag.destroy!
external.destroy!
expect(ExternalTag.exists?(external.id)).to be(true)
expect(create(:tag).id).to be > external.id
expect(create(:external_tag).id).to be > external.id
end
it 'retains existing overlapping IDs without renumbering on update or insert' do
it 'retains overlapping IDs after a tag update and a new allocation' do
tag = create(:tag)
external = create(:external_tag)
external.update_columns(id: tag.id)
tag.update!(category: :meta)
external.update!(name: 'updated_external')
before_tag = tag.reload.attributes
before_external = external.reload.attributes
+15 -5
ファイルの表示
@@ -47,24 +47,34 @@ RSpec.describe VersionRecord, type: :model do
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
linked_tags: '',
linked_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
expect {
version.update!(name: 'nico:changed')
version.update!(linked_tag_ids: '123')
}.to raise_error(ActiveRecord::ReadOnlyRecord)
end
it 'rejects discard and restore versions for external tags' do
[:discard, :restore].each do |event_type|
version = NicoTagVersion.new(
external_tag: nico_tag, version_no: 1, event_type:,
linked_tag_ids: '', created_at: Time.current)
expect(version).not_to be_valid
expect(version.errors[:event_type]).not_to be_empty
end
end
it 'prevents NicoTagVersion destroy' do
version = NicoTagVersion.create!(
external_tag: nico_tag,
version_no: 1,
event_type: 'create',
name: "nico:#{ nico_tag.name }",
linked_tags: '',
linked_tag_ids: '',
created_at: Time.current,
created_by_user: nil
)
+114 -6
ファイルの表示
@@ -19,7 +19,7 @@ RSpec.describe 'Counts table API', type: :request do
names: { ja: { name: 'alpha', script_code: 'Jpan', auto_generated: false, aliases: [] } },
created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', name: 'beta', linked_tags: '', created_at: Time.utc(2023))
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
snapshots = [
{ tag_id: tag.id, version_no: 1, name: 'alpha', category: 'general', sections: [] },
{ external_tag_id: external.id }]
@@ -50,6 +50,115 @@ RSpec.describe 'Counts table API', type: :request do
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
context 'nico link ID snapshots through the V1 field name' do
let(:base) do
super().merge(resource: 'nico_tags')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s,
created_at: Time.utc(2024, 1, 1))
NicoTagVersion.create!(external_tag: external, version_no: 3,
event_type: 'update', linked_tag_ids: '',
created_at: Time.utc(2024, 2, 1))
end
it 'returns ID values under linked_tags without exposing the physical column key' do
result = query
expect(versions(result)).to eq([1, 2, 3])
expect(result['versions'].map { _1.fetch('linked_tags') }).to eq(['', tag.id.to_s, ''])
result['versions'].each do |row|
expect(row).not_to have_key('linked_tag_ids')
expect(row.fetch('name')).to eq('nico:beta')
end
end
it 'preloads external names separately for aliased link-change details' do
statements = []
listener = lambda do |_name, _start, _finish, _id, payload|
statements << payload[:sql]
end
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
result = query(axis('changed_field', 'linked_tags'))
expect(versions(result)).to eq([2, 3])
expect(result.fetch('versions').map { _1.fetch('name') })
.to eq(['nico:beta', 'nico:beta'])
end
details = statements.find { _1.include?('AS counts_latest') }
expect(details).not_to be_nil
expect(details).not_to include('JOIN `external_tags`')
names = statements.select { _1.match?(/SELECT .* FROM `external_tags`/) }
expect(names.length).to eq(1)
end
it 'advertises the legacy field, label and default columns only' do
get '/counts/definitions/nico_tags', params: { locale: 'ja' }
expect(response).to have_http_status(:ok)
body = response.parsed_body
['fields', 'filter_fields', 'output_fields'].each do |collection|
keys = body.fetch(collection).map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
field = body.fetch('output_fields').find { _1['key'] == 'linked_tags' }
expect(field.fetch('label')).to eq('counts:attributes.linked_tags')
body.fetch('axes').each_value do |keys|
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
body.fetch('defaults').select { _1['mode'] == 'details' }.each do |entry|
settings = entry.fetch('params').to_h { [_1.fetch('key'), _1.fetch('value')] }
expect(settings.fetch('columns').split).to include('name', 'linked_tags')
expect(settings.fetch('columns').split).not_to include('linked_tag_ids')
end
end
it 'filters IDs and legacy drills and compares link changes through linked_tags' do
predicate = { conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(axis('linked_tags', tag.id.to_s, root: 'drill')))).to eq([2])
expect(versions(query(axis('changed_field', 'linked_tags')))).to eq([2, 3])
expect(versions(query(predicate.merge('conditions.0.value' => 'alpha')))).to be_empty
end
it 'sorts ID snapshots through the public field in both directions' do
expect(versions(query(sort: 'linked_tags:asc version_no:asc'))).to eq([1, 3, 2])
expect(versions(query(sort: 'linked_tags:desc version_no:asc'))).to eq([2, 1, 3])
end
it 'restores historical link IDs before applying a latest snapshot filter' do
predicate = { definition: 'latest', at: '2024-01-15T00:00:00Z',
conditions: '', 'conditions.0.field' => 'linked_tags',
'conditions.0.operator' => 'eq', 'conditions.0.value' => tag.id.to_s }
expect(versions(query(predicate))).to eq([2])
expect(versions(query(predicate.except(:at)))).to be_empty
end
it 'groups by linked_tags ID values and accepts the resulting drill condition' do
sign_in_as(create(:user, :admin))
result = query(mode: 'summary', rows: 'linked_tags', cols: '', sort: '')
expect(result.fetch('cells')).to match_array([
{ 'values' => [''], 'count' => 2 },
{ 'values' => [tag.id.to_s], 'count' => 1 }])
expect(versions(query(axis('linked_tags', tag.id.to_s)))).to eq([2])
end
it 'rejects the physical column as a public filter, axis, sort or change field' do
sign_in_as(create(:user, :admin))
[
axis('linked_tag_ids', tag.id.to_s),
axis('changed_field', 'linked_tag_ids'),
{ mode: 'summary', rows: 'linked_tag_ids', cols: '', sort: '' },
{ sort: 'linked_tag_ids:asc' }].each do |invalid|
get '/counts/query', params: base.merge(invalid)
expect(response).to have_http_status(:bad_request), invalid.inspect
end
end
end
it 'identifies a totals timeout without exposing SQL in the error response' do
allow_any_instance_of(CountsPivotTotals).to receive(:call)
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
@@ -223,11 +332,10 @@ RSpec.describe 'Counts table API', type: :request do
expect(versions(query(tags: 'renamed-alpha'))).to eq([1, 2, 4])
end
it 'resolves external names from current registrations too' do
external.update!(name: 'renamed-beta')
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([1])
expect(versions(query(tags: 'nico:beta'))).to eq([])
expect(versions(query(tags: 'not:nico:renamed-beta'))).to eq([2, 3, 4])
it 'resolves immutable external names across historical snapshots' do
expect(versions(query(tags: 'nico:beta'))).to eq([1])
expect(versions(query(tags: 'nico:renamed-beta'))).to eq([])
expect(versions(query(tags: 'not:nico:beta'))).to eq([2, 3, 4])
end
end
+2 -2
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'error responses', type: :request do
patch "/tags/#{ tag.id }", params: { category: 'nico' }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.',
@@ -37,7 +37,7 @@ RSpec.describe 'error responses', type: :request do
put "/users/#{ user.id }", params: { name: 'a' * 256 }
expect(response).to have_http_status(:unprocessable_entity)
expect(response).to have_http_status(:unprocessable_content)
expect(json).to include(
'type' => 'validation_error',
'message' => '入力内容を確認してください.')
+8 -14
ファイルの表示
@@ -38,12 +38,9 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'filters by nico tag name, linked tag name, and link status' do
linked = create(:external_tag)
linked.update!(name: 'search_linked')
unlinked = create(:external_tag)
unlinked.update!(name: 'search_unlinked')
other = create(:external_tag)
other.update!(name: 'other')
linked = create(:external_tag, name: 'search_linked')
unlinked = create(:external_tag, name: 'search_unlinked')
other = create(:external_tag, name: 'other')
destination = create(:tag, category: :general)
primary_tag_name_for(destination, 'ja').update!(name: 'destination_search')
NicoTagRelation.create!(nico_tag: linked, tag: destination)
@@ -80,11 +77,9 @@ RSpec.describe 'NicoTags', type: :request do
end
it 'sorts by name and timestamps' do
older = create(:external_tag)
older.update!(name: 'ordered_a')
older = create(:external_tag, name: 'ordered_a')
older.update_columns(created_at: 2.days.ago)
newer = create(:external_tag)
newer.update!(name: 'ordered_b')
newer = create(:external_tag, name: 'ordered_b')
newer.update_columns(created_at: 1.day.ago)
older_post_tag =
PostExternalTag.create!(post: create(:post), external_tag: older)
@@ -189,9 +184,8 @@ RSpec.describe 'NicoTags', type: :request do
versions = nico_tag.reload.nico_tag_versions.order(:version_no)
expect(versions.map(&:event_type)).to eq(['create', 'update'])
expect(versions.last.linked_tags.split).to match_array([
'nico_linked_a',
'nico_linked_b'])
expect(versions.last.linked_tag_ids.split.map(&:to_i))
.to match_array([linked_a.id, linked_b.id])
expect(versions.last.created_by_user_id).to eq(admin.id)
end
@@ -210,7 +204,7 @@ RSpec.describe 'NicoTags', type: :request do
expect(json).to eq([])
expect(nico_tag.reload.linked_tags).to be_empty
expect(nico_tag.nico_tag_versions.order(:version_no).last)
.to have_attributes(linked_tags: '', created_by_user: member)
.to have_attributes(linked_tag_ids: '', created_by_user: member)
end
it 'returns the tags field error when a nico tag is specified directly' do
+1 -1
ファイルの表示
@@ -20,7 +20,7 @@ RSpec.describe 'Post history privacy', type: :request do
created_at: origin + index.hours)
end
NicoTagVersion.create!(external_tag: external, version_no: 1, event_type: 'create',
name: 'nico:external-history-tag', linked_tags: 'private-history-tag',
linked_tag_ids: tag.id.to_s,
created_at: origin)
['create', 'discard', 'restore', 'update'].each_with_index do |event, index|
snapshots =
+49
ファイルの表示
@@ -22,6 +22,55 @@ RSpec.describe 'Counts export contracts' do
.and_return(@counts_export_dir)
end
context 'nico link IDs with the V1 export field name' do
let(:tag) { create(:tag, primary_name: 'linked export tag') }
let(:external) { create(:external_tag, name: 'external export tag') }
let(:payload) do
super().merge('resource' => 'nico_tags', 'columns' => 'linked_tags',
'headers' => '["linked_tags"]')
end
before do
NicoTagVersion.create!(external_tag: external, version_no: 1,
event_type: 'create', linked_tag_ids: '', created_at: Time.utc(2023))
NicoTagVersion.create!(external_tag: external, version_no: 2,
event_type: 'update', linked_tag_ids: tag.id.to_s, created_at: Time.utc(2024))
end
it 'exports recorded IDs with the legacy JSON key and metadata' do
result = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
expect(result.fetch('data')).to eq([
{ 'linked_tags' => '' }, { 'linked_tags' => tag.id.to_s }])
expect(result.fetch('metadata').fetch('columns')).to eq(['linked_tags'])
keys = result.fetch('metadata').fetch('fields').map { _1.fetch('key') }
expect(keys).to include('linked_tags')
expect(keys).not_to include('linked_tag_ids')
end
it 'exports the selected legacy CSV header and ID values' do
body = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
expect(CSV.parse(body.delete_prefix("\uFEFF")))
.to eq([['linked_tags'], [''], [tag.id.to_s]])
end
it 'keeps the legacy link column in default JSON exports' do
options = payload.except('columns', 'headers')
result = JSON.parse(CountsTableExport.new(options).body.to_a.join)
expect(result.fetch('metadata').fetch('columns')).to include('linked_tags')
result.fetch('data').each do |row|
expect(row).to have_key('linked_tags')
expect(row).not_to have_key('linked_tag_ids')
end
end
it 'rejects the physical column as a selected CSV or JSON field' do
['csv', 'json'].each do |format|
options = payload.merge('format' => format, 'columns' => 'linked_tag_ids')
expect { CountsTableExport.new(options) }.to raise_error(ArgumentError)
end
end
end
context 'serialization after the authorised query' do
let(:rows) { [{ title: "=formula\n\"quoted\"" }, { title: 'second' }] }
let(:page) do
+18 -8
ファイルの表示
@@ -8,21 +8,23 @@ RSpec.describe NicoTagVersionRecorder do
described_class.record!(external_tag:, event_type:, created_by_user: member)
end
it 'records the external association and platform-qualified name' do
it 'records the external association and derives the platform-qualified name' do
version = record(:create)
expect(version).to have_attributes(
external_tag:, version_no: 1, event_type: 'create',
name: 'nico:raw tag[]', linked_tags: '', created_by_user: member)
name: 'nico:raw tag[]', linked_tag_ids: '', created_by_user: member)
expect(external_tag.reload.nico_tag_versions).to contain_exactly(version)
end
it 'uses the latest history number when the record has no version_no column' do
first = record(:create)
external_tag.update!(name: 'changed')
tag = create(:tag)
NicoTagRelation.create!(nico_tag: external_tag, tag:)
second = record(:update)
expect(second).to have_attributes(version_no: 2, name: 'nico:changed')
expect(second).to have_attributes(version_no: 2, name: 'nico:raw tag[]',
linked_tag_ids: tag.id.to_s)
expect(first.reload.name).to eq('nico:raw tag[]')
expect(external_tag.reload.has_attribute?(:version_no)).to be(false)
end
@@ -40,7 +42,7 @@ RSpec.describe NicoTagVersionRecorder do
expect(version).to have_attributes(
external_tag: external, name: 'registered_external:foo', version_no: 1,
event_type: 'create', linked_tags: '', created_by_user: member)
event_type: 'create', linked_tag_ids: '', created_by_user: member)
end
it 'returns the latest version without appending an unchanged snapshot' do
@@ -64,13 +66,21 @@ RSpec.describe NicoTagVersionRecorder do
expect(external_tag.nico_tag_versions).to contain_exactly(first)
end
it 'records sorted linked internal names and later link removal' do
it 'rejects discard and restore events for external tags' do
[:discard, :restore].each do |event_type|
expect { record(event_type) }
.to raise_error(ArgumentError, "Invalid event_type: #{ event_type }")
end
end
it 'records sorted linked internal IDs and later link removal' do
tags = ['z_link', 'a_link'].map { |name| create(:tag, primary_name: name, category: :general) }
tags.each { |tag| NicoTagRelation.create!(nico_tag: external_tag, tag:) }
expect(record(:create).linked_tags).to eq('a_link z_link')
expect(record(:create).linked_tag_ids).to eq(tags.map(&:id).sort.join(' '))
external_tag.linked_tags = [tags.first]
expect(record(:update)).to have_attributes(version_no: 2, linked_tags: 'z_link')
expect(record(:update)).to have_attributes(version_no: 2, linked_tag_ids: tags.first.id.to_s)
end
end
+3366
ファイルの表示
ファイル差分が大きすぎるため省略します 差分を読み込み
+12 -4
ファイルの表示
@@ -158,7 +158,15 @@ private ID の正誤に応じて許可可否を変えないため、条件の一
非公開列を含む列順変更は API の column_values_editable でも不可と通知する。
公開列の値を使うソートは引き続き利用できる。
明細には元の認可を適用し、抽出条件自体や取得済みの全言語値を表示設定で改変しない。
外部タグ履歴の linked_tags は名前しか記録していないため、非公開タグの記録名を含む
スナップショットを保守的に非公開とする。名前全体を空白区切りの境界で照合する。
区切りが曖昧な旧データでは過剰な秘匿の可能性はあるが、参照 ID がない旧形式から
名前の公開可否を楽観的に推定しない。
外部タグ履歴の linked_tag_ids は、連携する内部タグの ID を半角スペース区切りで
記録する。除票履歴のある内部タグの ID を含むスナップショットを非管理者へ公開しない。
名前や UI ロカールを参照 ID の判定に使わない。
外部タグ名は履歴に保存せず、external_tags の platform と name を参照する。
V1 Counts の公開フィールド名は linked_tags を維持し、DB の linked_tag_ids へ対応付ける。
明細・フィルタ・集計軸・変更項目・ソート・CSV / JSON 出力で同じ対応付けを使う。
既定列と既存の表示ラベルも linked_tags のままとし、値は ID のスペース区切りとする。
linked_tag_ids は物理カラム名であり、Counts の公開フィールドとしては受け付けない。
旧 URL や保存済み設定のフィールド指定は維持できるが、値にタグ名を指定した検索条件や
ドリル条件は ID 値への見直しが必要となる。旧履歴の名称は移行後の ID 列から復元できず、
現在の名称からの推定変換も行わない。
+4 -27
ファイルの表示
@@ -1,29 +1,6 @@
import autoprefixer from 'autoprefixer'
import tailwindcss from 'tailwindcss'
// Apply the same breakpoints to handwritten CSS and Tailwind utilities.
export const inlineBreakpoints = {
postcssPlugin: 'inline-breakpoints',
OnceExit (root) {
const queries = []
root.walkAtRules ('media', query => {
if (/\b(?:min|max)-width\s*:/.test (query.params))
queries.push (query)
})
for (const query of queries) {
const vertical = query.cloneAfter ({
params: query.params.replace (/\b(min|max)-width(?=\s*:)/g, '$1-height') })
for (const [branch, scope] of [
[query, ':where(html:not(:lang(mn-Mong)))'],
[vertical, ':where(html:lang(mn-Mong))']]) {
branch.walkRules (rule => {
rule.selectors = rule.selectors.map (selector => `${scope} ${selector}`)
})
}
}
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
export default {
plugins: [tailwindcss (), inlineBreakpoints, autoprefixer ()],
}
-52
ファイルの表示
@@ -1,52 +0,0 @@
import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import { describe, expect, it } from 'vitest'
import { inlineBreakpoints } from './postcss.config.js'
describe ('inline breakpoints', () => {
it ('uses height for vertical menus and width for horizontal menus', async () => {
const source = `
@media (min-width: 768px) { .menu, .account { display: flex } }
@media (min-width: 1024px) { .menu { font-size: 1rem } }
@media (max-width: 767px) { .mobile { display: block } }
@media (prefers-reduced-motion: reduce) { .menu { transition: none } }
`
const { css } = await postcss ([inlineBreakpoints]).process (
source, { from: 'menus.css' })
const rules = []
postcss.parse (css).walkRules (rule => {
rules.push ([rule.parent.params, rule.selector])
})
expect (rules).toEqual ([
['(min-width: 768px)',
':where(html:not(:lang(mn-Mong))) .menu, '
+ ':where(html:not(:lang(mn-Mong))) .account'],
['(min-height: 768px)',
':where(html:lang(mn-Mong)) .menu, :where(html:lang(mn-Mong)) .account'],
['(min-width: 1024px)', ':where(html:not(:lang(mn-Mong))) .menu'],
['(min-height: 1024px)', ':where(html:lang(mn-Mong)) .menu'],
['(max-width: 767px)', ':where(html:not(:lang(mn-Mong))) .mobile'],
['(max-height: 767px)', ':where(html:lang(mn-Mong)) .mobile'],
['(prefers-reduced-motion: reduce)', '.menu']])
})
it ('includes responsive utilities generated by Tailwind', async () => {
const { css } = await postcss ([tailwindcss ({
content: [{ raw: '<div class="md:hidden lg:text-base"></div>' }],
corePlugins: { preflight: false } }), inlineBreakpoints]).process (
'@tailwind utilities;', { from: 'utilities.css' })
const rules = []
postcss.parse (css).walkRules (rule => {
rules.push ([rule.parent.params, rule.selector])
})
expect (rules).toContainEqual ([
'(min-width: 768px)', ':where(html:not(:lang(mn-Mong))) .md\\:hidden'])
expect (rules).toContainEqual ([
'(min-height: 768px)', ':where(html:lang(mn-Mong)) .md\\:hidden'])
expect (rules).toContainEqual ([
'(min-height: 1024px)', ':where(html:lang(mn-Mong)) .lg\\:text-base'])
})
})
-94
ファイルの表示
@@ -1,94 +0,0 @@
Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts)
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
バイナリファイルは表示されません。
-11
ファイルの表示
@@ -1,11 +0,0 @@
Noto Sans Mongolian Regular, unmodified TrueType font.
Font copyright: Copyright 2018 Google Inc. All Rights Reserved.
- Source: https://github.com/notofonts/noto-fonts/tree/main/hinted/ttf/NotoSansMongolian
- Font: https://raw.githubusercontent.com/notofonts/noto-fonts/main/hinted/ttf/NotoSansMongolian/NotoSansMongolian-Regular.ttf
- License: SIL Open Font License 1.1; see the accompanying LICENSE.
- Retrieved: 2026-11-08.
Used by the Mongolian UI at `/mn-Mong`.
The font and license are served together from this directory.
+1 -20
ファイルの表示
@@ -1,10 +1,9 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createServer } from 'vite'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { generateHelpAssets, helpAssets } from './help-assets'
import { generateHelpAssets } from './help-assets'
import type { ArticleMeta, HelpChange, HelpSearchEntry, HelpTitleIndex } from '@/lib/help'
@@ -110,22 +109,4 @@ describe ('Build-time Help assets', () => {
it ('rejects an empty Help checkout', async () => {
await expect (generateHelpAssets (root)).rejects.toThrow ('No Help articles found')
})
it ('defers missing Help errors until a Help module is requested', async () => {
const server = await createServer ({
configFile: false,
root,
plugins: [helpAssets ()],
server: { middlewareMode: true, watch: null } })
try
{
await server.pluginContainer.buildStart ({})
await expect (server.pluginContainer.load ('\0virtual:help/articles'))
.rejects.toThrow ('ENOENT')
}
finally
{
await server.close ()
}
})
})
+5 -6
ファイルの表示
@@ -159,9 +159,10 @@ export const helpAssets = (): Plugin => {
configResolved (config) {
state.root = path.resolve (config.root, 'help')
},
buildStart () {
state.data = undefined
this.addWatchFile (state.root)
async buildStart () {
state.data = generateHelpAssets (state.root)
const generated = await data ()
generated.files.forEach (file => this.addWatchFile (file))
},
resolveId (id) {
return id.startsWith (prefix) ? `\0${ id }` : null
@@ -169,9 +170,7 @@ export const helpAssets = (): Plugin => {
async load (id) {
if (!(id.startsWith (`\0${ prefix }`)))
return null
const generated = await data ()
generated.files.forEach (file => this.addWatchFile (file))
const source = generated.modules.get (id.slice (1))
const source = (await data ()).modules.get (id.slice (1))
if (source == null)
throw new Error (`Unknown Help asset: ${ id.slice (1) }`)
return source
+19 -30
ファイルの表示
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
import { isApiError } from '@/lib/api'
import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath,
localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isCountsPath } from '@/lib/routeLocales'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale, useUILocale, useVerticalPreview } from '@/lib/useLocale'
import { useLocale } from '@/lib/useLocale'
import { applyClientAnimationMode,
applyClientAppearance,
fetchUserThemeSlots,
@@ -46,7 +46,7 @@ import ServiceUnavailable from '@/pages/ServiceUnavailable'
import type { ComponentType, Dispatch, FC, ReactNode, SetStateAction } from 'react'
import type { RouteMatch } from 'react-router-dom'
import type { AvailableLocale, LocaleCode, UILocaleCode } from '@/lib/locales'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { PageRoute } from '@/lib/routeLocales'
import type { User } from '@/types'
@@ -267,21 +267,15 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const client = useQueryClient ()
const location = useLocation ()
const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const route = resolvePageRoute (location.pathname)
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
const pagePath = unlocalisedPath (location.pathname)
const countsPage = pagePath === '/counts' || pagePath.startsWith ('/counts/')
const switchLocale = async (target: UILocaleCode): Promise<string> => {
const targetLocale = target === 'mn-Mong' ? 'ja' : target
if (targetLocale === locale)
return switchLocalePath (location, target)
const switchLocale = async (targetLocale: LocaleCode): Promise<string> => {
const resolve = route?.route.handle?.switchLocale
const destination =
(resolve != null && route != null)
? await resolve ({ location, params: route.params, locale, targetLocale })
return (resolve != null && route != null)
? resolve ({ location, params: route.params, locale, targetLocale })
: switchLocalePath (location, targetLocale)
return target === 'mn-Mong' ? `/mn-Mong${unlocalisedPath (destination)}` : destination
}
const [user, setUserState] = useState<User | null> (
() => client.getQueryData<User> (sessionKeys.current) ?? null)
@@ -399,8 +393,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
<motion.div
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
transition={{ layout: appLayoutTransition }}
data-vertical-preview={verticalPreview || undefined}
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
<RouteTransitionWrapper
user={user}
@@ -418,9 +411,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const LocaleRoute: FC = () => {
const { locale } = useParams ()
const uiLocale = useUILocale ()
const location = useLocation ()
const verticalPreview = isVerticalPreviewPath (location.pathname)
const { i18n } = useTranslation ()
const { data, isError } = useQuery ({
queryKey: localesKeys.index,
@@ -430,30 +421,29 @@ const LocaleRoute: FC = () => {
refetchOnMount: true,
refetchOnWindowFocus: true,
refetchOnReconnect: true })
const contentLocale = verticalPreview ? 'ja' : locale
const activeLocale =
(isLocaleCode (contentLocale)
&& data?.locales.some (entry => entry.code === contentLocale))
? contentLocale
(isLocaleCode (locale)
&& data?.locales.some (entry => entry.code === locale))
? locale
: null
useLayoutEffect (() => {
if (activeLocale != null)
document.documentElement.lang = uiLocale
if (activeLocale != null && i18n.language !== uiLocale)
void i18n.changeLanguage (uiLocale)
}, [activeLocale, uiLocale, i18n])
document.documentElement.lang = activeLocale
if (activeLocale != null && i18n.language !== activeLocale)
void i18n.changeLanguage (activeLocale)
}, [activeLocale, i18n])
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null)
if (!(isLocaleCode (locale)) && redirect == null)
return <NotFound/>
if (isError && data == null)
return <ServiceUnavailable/>
if (data == null)
return null
if (isLocaleCode (locale) || verticalPreview)
if (isLocaleCode (locale))
{
return data.locales.some (entry => entry.code === contentLocale)
return data.locales.some (entry => entry.code === locale)
? <RoutedApp locales={data.locales}/>
: <NotFound/>
}
@@ -465,8 +455,7 @@ const LocaleRoute: FC = () => {
const LocaleRedirect = ({ to }: { to: string }) => {
const locale = useLocale ()
const location = useLocation ()
return <Navigate to={localiseTo (
{ ...location, pathname: to }, locale, location.pathname)} replace/>
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
}
+1 -1
ファイルの表示
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
const cardRef = useRef<HTMLDivElement> (null)
return (
<div className="post-list flex flex-wrap gap-6 p-4">
<div className="flex flex-wrap gap-6 p-4">
{posts.map ((post, i) => {
const sharedId = `page-${ post.id }`
const layoutId = sharedId
+5 -9
ファイルの表示
@@ -1,10 +1,9 @@
import { useQueryClient } from '@tanstack/react-query'
import { forwardRef, useMemo } from 'react'
import { flushSync } from 'react-dom'
import { createPath, useLocation } from 'react-router-dom'
import { createPath } from 'react-router-dom'
import { isVerticalPreviewPath, localeFromPath,
localiseTo, unlocalisedPath } from '@/lib/locales'
import { localiseTo } from '@/lib/locales'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers'
@@ -39,18 +38,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useLocaleNavigate ()
const locale = useLocale ()
const { pathname } = useLocation ()
const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const path = useMemo (
() => {
const localised = localiseTo (to, locale, pathname)
const destination = typeof localised === 'string' ? localised : createPath (localised)
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
const localised = localiseTo (to, locale)
return typeof localised === 'string' ? localised : createPath (localised)
},
[to, locale, pathname],
[to, locale],
)
const url = useMemo (() => {
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
+4 -8
ファイルの表示
@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useVerticalPreview } from '@/lib/useLocale'
import { cn } from '@/lib/utils'
import { useEffect, useRef, useState } from 'react'
import type { FC } from 'react'
@@ -13,7 +11,7 @@ type Props = {
title?: string }
const DESKTOP_MARQUEE_MEDIA =
'(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
'(min-width: 768px) and (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)'
const MARQUEE_START_DELAY_MS = 1600
const MARQUEE_END_HOLD_MS = 2400
const MARQUEE_SCROLL_PX_PER_SECOND = 43
@@ -23,7 +21,6 @@ const MIN_MARQUEE_OVERFLOW_PX = 1
const ResponsiveMarqueeText: FC<Props> = (
{ text, className, truncateOnMobile = false, title },
) => {
const verticalPreview = useVerticalPreview ()
const behaviourSettings = useClientBehaviourSettings ()
const animationMode = behaviourSettings.animation ?? 'normal'
const outerRef = useRef<HTMLSpanElement | null> (null)
@@ -72,8 +69,7 @@ const ResponsiveMarqueeText: FC<Props> = (
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
return
const sizeMedia = verticalPreview ? '(min-height: 768px)' : '(min-width: 768px)'
const media = window.matchMedia (`${ sizeMedia } and ${ DESKTOP_MARQUEE_MEDIA }`)
const media = window.matchMedia (DESKTOP_MARQUEE_MEDIA)
const update = () => {
setDesktopMarqueeEnabled (media.matches)
}
@@ -84,7 +80,7 @@ const ResponsiveMarqueeText: FC<Props> = (
return () => {
media.removeEventListener ('change', update)
}
}, [verticalPreview])
}, [])
useEffect (() => {
const clearScheduled = () => {
+5 -8
ファイルの表示
@@ -79,9 +79,7 @@ const TagLink: FC<Props> = ({ tag,
} as CSSProperties
const spanClass = 'tag-link-colour'
const linkClass = 'tag-link-colour tag-link-hover-colour'
const textClass = cn (
'group [min-inline-size:0] [max-inline-size:100%]',
'overflow-hidden align-bottom')
const textClass = 'group min-w-0 max-w-full overflow-hidden align-bottom'
const markerWrapClass = 'shrink-0 self-start md:self-auto'
const countClass = 'shrink-0 self-end md:self-auto'
const matchedAlias = isFullTag (tag) ? tag.matchedAlias : null
@@ -91,8 +89,7 @@ const TagLink: FC<Props> = ({ tag,
return (
<span
className={cn (
'inline-flex [min-inline-size:0] [max-inline-size:100%] flex-nowrap',
'items-stretch align-baseline',
'inline-flex min-w-0 max-w-full flex-nowrap items-stretch align-baseline',
'gap-x-1 md:items-baseline')}>
{(linkFlg && withWiki && isFullTag (tag) && tag.category !== 'nico') && (
<span className={markerWrapClass}>
@@ -156,9 +153,9 @@ const TagLink: FC<Props> = ({ tag,
</span>)}
{nestLevel > 0 && (
<span
className="ms-1 me-1 shrink-0"
style={{ paddingInlineStart: `${ (nestLevel - 1) }rem` }}>
<span className="tag-tree-arrow">↳</span>
className="ml-1 mr-1 shrink-0"
style={{ paddingLeft: `${ (nestLevel - 1) }rem` }}>
↳
</span>)}
{matchedAlias != null && (
<>
+3 -4
ファイルの表示
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import { apiGet } from '@/lib/api'
import { inputClass } from '@/lib/utils'
@@ -18,7 +18,6 @@ import type { Tag } from '@/types'
const TagSearch: FC = () => {
const { message } = useUITranslation ('shared')
const locale = useLocale ()
const verticalPreview = useVerticalPreview ()
const location = useLocation ()
const navigate = useLocaleNavigate ()
const compositionFlgRef = useRef (false)
@@ -106,13 +105,13 @@ const TagSearch: FC = () => {
switch (ev.key)
{
case verticalPreview ? 'ArrowRight' : 'ArrowDown':
case 'ArrowDown':
ev.preventDefault ()
setActiveIndex (i => Math.min (i + 1, suggestions.length - 1))
setSuggestionsVsbl (true)
break
case verticalPreview ? 'ArrowLeft' : 'ArrowUp':
case 'ArrowUp':
ev.preventDefault ()
setActiveIndex (i => Math.max (i - 1, -1))
setSuggestionsVsbl (true)
+1 -1
ファイルの表示
@@ -15,7 +15,7 @@ const TagSearchBox: FC<Props> = ({ suggestions, activeIndex, onSelect }) => {
return
return (
<ul className="tag-search-suggestions absolute left-0 right-0 z-50 w-full
<ul className="absolute left-0 right-0 z-50 w-full
bg-gray-100 dark:bg-gray-800 border dark:border-gray-600
rounded shadow">
{suggestions.map ((tag, i) => (
+50 -85
ファイルの表示
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useLocation } from 'react-router-dom'
import { useLocale, useLocaleNavigate, useUILocale, useVerticalPreview } from '@/lib/useLocale'
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
import Separator from '@/components/MenuSeparator'
import PrefetchLink from '@/components/PrefetchLink'
@@ -12,9 +12,9 @@ import TopNavUser from '@/components/TopNavUser'
import TranslatedText from '@/components/TranslatedText'
import { toast } from '@/components/ui/use-toast'
import { WikiIdBus } from '@/lib/eventBus/WikiIdBus'
import { isUILocaleCode,
import { isLocaleCode,
localeFromPath,
switchLocalePath,
uiLocaleFromPath,
unlocalisedPath } from '@/lib/locales'
import { uiMessage, uiMessageLanguage } from '@/lib/messages'
import { useUITranslation } from '@/lib/useUITranslation'
@@ -28,13 +28,13 @@ import { fetchWikiPage } from '@/lib/wiki'
import type { FC, MouseEvent } from 'react'
import type { AvailableLocale, UILocaleCode } from '@/lib/locales'
import type { AvailableLocale, LocaleCode } from '@/lib/locales'
import type { Material, Menu, MenuVisibleItem, Tag, User } from '@/types'
type Props = {
user: User | null
locales: AvailableLocale[]
resolveLocaleSwitch?: (locale: UILocaleCode) => Promise<string> }
resolveLocaleSwitch?: (locale: LocaleCode) => Promise<string> }
type NavDensity = 'auto' | 'comfortable' | 'compact'
@@ -47,7 +47,7 @@ export const menuOutline = (
pathName: string },
): Menu => {
const pathName = unlocalisedPath (fullPathname)
const locale = uiLocaleFromPath (fullPathname)
const locale = localeFromPath (fullPathname) ?? 'ja'
const message = (
key: Parameters<typeof uiMessage>[1],
@@ -137,10 +137,7 @@ export const menuOutline = (
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const { message, label } = useUITranslation ('shared')
const locale = useLocale ()
const uiLocale = useUILocale ()
const verticalPreview = useVerticalPreview ()
const uiLocales = locales.filter (entry => isUILocaleCode (entry.code))
const localeName = uiLocales.find (entry => entry.code === uiLocale)?.name
const localeName = locales.find (entry => entry.code === locale)?.name
const navigate = useLocaleNavigate ()
const location = useLocation ()
const currentLocation = useRef (location)
@@ -183,12 +180,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
let node: HTMLElement | null = el
while (node != null && node !== nav)
{
left += verticalPreview ? node.offsetTop : node.offsetLeft
left += node.offsetLeft
const parent: Element | null = node.offsetParent
node = parent instanceof HTMLElement ? parent : null
}
const width = node === nav
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
const width = node === nav ? el.offsetWidth : 0
const visible = width > 0
setHL (current =>
(current.left === left && current.width === width && current.visible === visible)
@@ -206,7 +202,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const [moreVsbl, setMoreVsbl] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1)
const [submenuHeight, setSubmenuHeight] = useState (0)
const [submenuWidth, setSubmenuWidth] = useState (0)
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
const wikiIdStr = String (wikiId ?? '')
@@ -260,22 +255,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
animationsOff
? undefined
: (reducedAnimations
? 'transition-[transform,width,height] duration-75 ease-linear'
: 'transition-[transform,width,height] duration-200 ease-out')
const submenuItemMotion = animationsOff ? { } : {
initial: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: { x: verticalPreview ? -40 : 0,
y: verticalPreview ? 0 : -40, opacity: 0 } }
const submenuVariants = {
enter: (direction: -1 | 1) => ({
x: verticalPreview ? direction * 24 : 0,
y: verticalPreview ? 0 : direction * 24, opacity: 0 }),
centre: { x: 0, y: 0, opacity: 1 },
exit: (direction: -1 | 1) => ({
x: verticalPreview ? (-direction) * 24 : 0,
y: verticalPreview ? 0 : (-direction) * 24, opacity: 0 }) }
? 'transition-[transform,width] duration-75 ease-linear'
: 'transition-[transform,width] duration-200 ease-out')
const prevActiveIdxRef = useRef<number> (activeIdx)
@@ -299,8 +280,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
let active = true
const update = () => {
const desktopMedia = verticalPreview ? '(min-height: 768px)' : '(min-width: 768px)'
if (!(window.matchMedia (desktopMedia).matches))
if (!(window.matchMedia ('(min-width: 768px)').matches))
{
setDesktop (false)
setDensity ('auto')
@@ -317,25 +297,16 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{
layout.setAttribute ('data-density', candidate)
const style = getComputedStyle (bar)
const primarySize =
verticalPreview
? Math.max (primary.getBoundingClientRect ().height, primary.scrollHeight)
: Math.max (primary.getBoundingClientRect ().width, primary.scrollWidth)
const accountSize =
const primaryWidth = Math.max (
primary.getBoundingClientRect ().width, primary.scrollWidth)
const accountWidth =
account == null
? 0
: (verticalPreview
? Math.max (account.getBoundingClientRect ().height, account.scrollHeight)
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth))
const padding =
verticalPreview
? parseFloat (style.paddingTop) + parseFloat (style.paddingBottom)
: parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
const gap = verticalPreview ? style.rowGap : style.columnGap
const required = primarySize + accountSize + padding
+ (account == null ? 0 : parseFloat (gap))
const available = verticalPreview ? bar.clientHeight : bar.clientWidth
if (Math.ceil (required) <= available)
: Math.max (account.getBoundingClientRect ().width, account.scrollWidth)
const required = primaryWidth + accountWidth
+ parseFloat (style.paddingLeft) + parseFloat (style.paddingRight)
+ (account == null ? 0 : parseFloat (style.columnGap))
if (Math.ceil (required) <= bar.clientWidth)
return candidate
}
return null
@@ -380,8 +351,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
removeEventListener ('resize', schedule)
document.fonts?.removeEventListener ('loadingdone', schedule)
}
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
menuLabels, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
useEffect (() => {
if (desktop == null || layoutReady)
@@ -408,8 +378,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
measure (moreVsbl ? -1 : activeIdx)
if (!(desktop))
setMoreVsbl (false)
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
menuLabels, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId)
@@ -423,7 +392,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
const update = () => {
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
}
const observer =
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
@@ -435,7 +403,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
observer?.disconnect ()
removeEventListener ('resize', update)
}
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
}, [activeIdx, moreVsbl, desktop, density, locale])
useEffect (() => {
setMenuOpen (false)
@@ -447,18 +415,17 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
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={uiLocale}>{localeName}</LocalisedText>
<LocalisedText language={locale}>{localeName}</LocalisedText>
</span>
<select
aria-label={formatUIString (localeName ?? '', uiLocale)}
lang={uiLocale}
value={uiLocale}
aria-label={formatUIString (localeName ?? '', locale)}
value={locale}
disabled={switchingLocale}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
onChange={async event => {
const nextLocale = event.target.value
if (isUILocaleCode (nextLocale)
&& uiLocales.some (entry => entry.code === nextLocale))
if (isLocaleCode (nextLocale)
&& locales.some (entry => entry.code === nextLocale))
{
const request = ++localeSwitchRequest.current
setSwitchingLocale (true)
@@ -483,8 +450,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
}
}
}}>
{uiLocales.map (entry => (
<option key={entry.code} value={entry.code} lang={entry.code}>
{locales.map (entry => isLocaleCode (entry.code) && (
<option key={entry.code} value={entry.code}>
{formatUIString (entry.name, entry.code)}
</option>))}
</select>
@@ -494,7 +461,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div ref={layoutRef} className="top-nav-layout w-full min-w-0 shrink-0"
data-desktop={desktop ?? undefined} data-density={density}
data-layout-ready={layoutReady}>
<nav ref={barRef} lang={uiMessageLanguage (uiLocale, 'posts')}
<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
@@ -512,11 +479,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
className={cn ('absolute inset-y-0 h-12',
'top-nav-highlight',
highlightTransitionClass)}
style={{ width: verticalPreview ? '100%' : hl.width,
height: verticalPreview ? hl.width : undefined,
transform: verticalPreview
? `translateY(${ hl.left }px)`
: `translateX(${ hl.left }px)`,
style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`,
opacity: hl.visible ? 1 : 0 }}/>
{visibleMenu.map ((item, i) => (
@@ -590,9 +554,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
layout
className="top-nav-desktop-submenu relative z-20 shrink-0
overflow-hidden top-nav-submenu"
animate={verticalPreview && desktop
? { width: submenuWidth, height: '100%' }
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
onMouseLeave={() => {
if (moreVsbl)
setMoreVsbl (false)
@@ -604,8 +566,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
onLayoutAnimationComplete={() => {
measure (highlightTargetRef.current)
}}>
<div ref={submenuContentRef}
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
{moreVsbl
? (
moreMenu.map ((item, i) => (
@@ -615,11 +576,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
{...((item.visible ?? true)
? { layoutId: `menu-${ menu.indexOf (item) }` }
: (animationsOff ? { }
: { initial: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
: { initial: { x: 40, y: -40, opacity: 0 },
animate: { x: 0, y: 0, opacity: 1 },
exit: {
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
exit: { x: 40, y: -40, opacity: 0 } }))}
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
className="z-10 min-w-0 px-3 py-2 font-bold">
<h2>
@@ -640,7 +599,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition}
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
{subItem.component}
</motion.div>)
: (
@@ -650,7 +612,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
transition={topNavTransition}
{...(visibleMenu[activeIdx] === item
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
: submenuItemMotion)}>
: (animationsOff ? { }
: { initial: { y: -40, opacity: 0 },
animate: { y: 0, opacity: 1 },
exit: { y: -40, opacity: 0 } }))}>
<PrefetchLink
to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
@@ -668,8 +633,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<div className="relative min-h-[40px]">
{animationsOff
? (
<div className="top-nav-submenu-items flex min-h-[40px]
flex-wrap items-center px-3">
<div className="flex min-h-[40px] flex-wrap items-center px-3">
{(visibleMenu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
@@ -698,9 +662,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
<motion.div
key={activeIdx}
custom={dir}
variants={submenuVariants}
className="top-nav-submenu-items relative flex min-h-[40px]
flex-wrap items-center px-3"
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="relative flex min-h-[40px] flex-wrap items-center px-3"
initial="enter"
animate="centre"
exit="exit"
+1 -1
ファイルの表示
@@ -8,7 +8,7 @@ type Props = {
const Form: FC<Props> = ({ children, className }) => (
<div className={cn ('form-layout mx-auto max-w-xl space-y-4 p-4', className)}>
<div className={cn ('mx-auto max-w-xl space-y-4 p-4', className)}>
{children}
</div>)
+1 -1
ファイルの表示
@@ -61,7 +61,7 @@ const Pagination: FC<Props> = ({ page, totalPages, siblingCount = 3 }) => {
const pages = getPages (page, totalPages, siblingCount)
return (
<nav className="pagination mt-4 flex justify-center"
<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">
+1 -1
ファイルの表示
@@ -12,7 +12,7 @@ export default forwardRef<HTMLTextAreaElement, Props> (
<textarea
ref={ref}
aria-invalid={invalid}
className={cn ('rounded border [inline-size:100%] p-2 [block-size:8rem]',
className={cn ('rounded border w-full p-2 h-32',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'focus:border-red-500 focus:outline-none focus:ring-2',
+5 -58
ファイルの表示
@@ -1,10 +1,7 @@
import { motion } from 'framer-motion'
import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { useVerticalPreview } from '@/lib/useLocale'
import { cn } from '@/lib/utils'
import type { FC, ReactNode } from 'react'
@@ -12,71 +9,21 @@ import type { FC, ReactNode } from 'react'
type Props = {
children: ReactNode
className?: string
animateLayout?: boolean
verticalContent?: boolean }
animateLayout?: boolean }
const MainArea: FC<Props> = (
{ children, className, animateLayout = true, verticalContent = true },
) => {
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const horizontal = verticalPreview && verticalContent
const { pathname, search } = useLocation ()
const rootRef = useRef<HTMLElement | null> (null)
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } })
useEffect (() => {
if (horizontal && rootRef.current != null)
rootRef.current.scrollLeft = 0
}, [horizontal, pathname, search])
useEffect (() => {
const root = rootRef.current
if (!horizontal || root == null)
return
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey || event.shiftKey
|| event.deltaX !== 0 || event.deltaY === 0 || event.defaultPrevented)
return
// Nested tables, forms and code blocks retain their own scrolling.
let node = event.target instanceof HTMLElement ? event.target : null
while (node != null && node !== root)
{
if (node.matches ('input, select, textarea, [contenteditable="true"]'))
return
const style = getComputedStyle (node)
if ((/auto|scroll/.test (style.overflowY) && node.scrollHeight > node.clientHeight)
|| (/auto|scroll/.test (style.overflowX) && node.scrollWidth > node.clientWidth))
return
node = node.parentElement
}
const unit = event.deltaMode === 1 ? 16 : (event.deltaMode === 2 ? root.clientWidth : 1)
const delta = event.deltaY * unit
if ((delta < 0 && root.scrollLeft <= 0)
|| (delta > 0 && root.scrollLeft + root.clientWidth >= root.scrollWidth))
return
event.preventDefault ()
root.scrollLeft += delta
}
root.addEventListener ('wheel', handleWheel, { passive: false })
return () => root.removeEventListener ('wheel', handleWheel)
}, [horizontal])
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
return (
<motion.main
ref={rootRef}
data-vertical-content={horizontal || undefined}
tabIndex={horizontal ? 0 : undefined}
transition={{ layout: layoutTransition }}
className={cn ('main-area flex-1 overflow-y-auto p-4', className)}
className={cn ('flex-1 overflow-y-auto p-4', className)}
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
{children}
</motion.main>)
+103 -126
ファイルの表示
@@ -1,14 +1,13 @@
import { motion } from 'framer-motion'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { clientAnimationTransition } from '@/lib/clientAnimation'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import { useVerticalPreview } from '@/lib/useLocale'
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
type SidebarSide = 'left' | 'right'
@@ -24,12 +23,12 @@ type Props = {
type ResizeState = {
pointerId: number
startSize: number
startWidth: number
startX: number
startY: number
verticalPreview: boolean
side: SidebarSide }
type PendingPressState = ResizeState & { startY: number }
const DEFAULT_SIDEBAR_WIDTH = 256
const MIN_SIDEBAR_WIDTH = 192
const MIN_MAIN_WIDTH = 360
@@ -38,97 +37,84 @@ const LONG_PRESS_DELAY_MS = 350
const TOUCH_MOVE_THRESHOLD_PX = 8
const storageKeyForSidebar = (
sidebarKey: string,
side: SidebarSide,
verticalPreview = false,
): string =>
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
`sidebar:${ sidebarKey }:${ side }:width`
const getSidebarMaxWidth = (containerSize: number): number => {
const getSidebarMaxWidth = (containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const containerMaxWidth = Math.floor (containerSize - MIN_MAIN_WIDTH)
const containerMaxWidth = Math.floor (containerWidth - MIN_MAIN_WIDTH)
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
}
const readStoredSidebarSize = (
const readStoredSidebarWidth = (
sidebarKey: string,
side: SidebarSide,
verticalPreview: boolean,
): number => {
side: SidebarSide): number => {
if (typeof window === 'undefined')
return DEFAULT_SIDEBAR_WIDTH
try
{
const value = window.localStorage.getItem (
storageKeyForSidebar (sidebarKey, side, verticalPreview))
const size = value == null ? Number.NaN : Number (value)
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
}
catch
{
return DEFAULT_SIDEBAR_WIDTH
}
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
const width = value == null ? Number.NaN : Number (value)
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
}
const SidebarComponent: FC<Props> = (
{ children, className, maxWidth, maxWidthRatio, animateLayout = true,
onWidthChange, sidebarKey, side = 'left' },
) => {
const SidebarComponent: FC<Props> = ({
children,
className,
maxWidth,
maxWidthRatio,
animateLayout = true,
onWidthChange,
sidebarKey,
side = 'left',
}) => {
const { message } = useUITranslation ('shared')
const behaviourSettings = useClientBehaviourSettings ()
const verticalPreview = useVerticalPreview ()
const animationMode = behaviourSettings.animation ?? 'normal'
const layoutTransition = clientAnimationTransition (
animationMode,
{ normal: { duration: .2, ease: 'easeOut' as const } })
{ normal: { duration: .2, ease: 'easeOut' as const } },
)
const rootRef = useRef<HTMLDivElement | null> (null)
const maxWidthRef = useRef (maxWidth)
const maxWidthRatioRef = useRef (maxWidthRatio)
const getContainerSize = useCallback (() => {
const container = rootRef.current?.closest ('[data-sidebar-container]')
return verticalPreview
? container?.clientHeight ?? window.innerHeight
: container?.clientWidth ?? window.innerWidth
}, [verticalPreview])
const getClampedSize = useCallback ((
nextSize: number,
containerSize: number,
): number => {
if (verticalPreview)
{
const maximum = Math.max (64, Math.min (
window.innerHeight * .6, containerSize - 160))
return Math.min (maximum, Math.max (64, nextSize))
}
const getContainerWidth = useCallback (() => {
const containerWidth = rootRef.current
?.closest ('[data-sidebar-container]')
?.clientWidth
?? window.innerWidth
return containerWidth
}, [])
const getClampedWidth = useCallback ((
nextWidth: number,
containerWidth: number): number => {
const viewportWidth =
typeof window === 'undefined' ? containerSize : window.innerWidth
typeof window === 'undefined' ? containerWidth : window.innerWidth
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
const ratioMaxWidth = maxWidthRatioRef.current == null
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
const configuredMaxWidth = maxWidthRef.current == null
? getSidebarMaxWidth (containerSize)
? getSidebarMaxWidth (containerWidth)
: Math.max (MIN_SIDEBAR_WIDTH,
Math.min (viewportMaxWidth, maxWidthRef.current))
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
Math.min (ratioMaxWidth, configuredMaxWidth))
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
}, [verticalPreview])
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
}, [])
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
const [resizing, setResizing] = useState (false)
const sizeRef = useRef (size)
const widthRef = useRef (width)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
const pendingPressRef = useRef<ResizeState | null> (null)
const pendingPressRef = useRef<PendingPressState | null> (null)
const resizeRef = useRef<ResizeState | null> (null)
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
@@ -162,7 +148,7 @@ const SidebarComponent: FC<Props> = (
userSelect: document.body.style.userSelect }
}
document.body.style.cursor = state.verticalPreview ? 'row-resize' : 'col-resize'
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [clearLongPressTimer])
@@ -173,50 +159,43 @@ const SidebarComponent: FC<Props> = (
const state = resizeRef.current
resizeRef.current = null
try
{
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
String (sizeRef.current))
}
catch
{
// Resizing remains usable when browser storage is unavailable.
}
if (state != null)
window.localStorage.setItem (
storageKeyForSidebar (sidebarKey, state.side),
String (widthRef.current))
setResizing (false)
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
const applySize = useCallback ((nextSize: number) => {
if (nextSize === sizeRef.current)
const applyWidth = useCallback ((nextWidth: number) => {
if (nextWidth === widthRef.current)
return
sizeRef.current = nextSize
setSize (nextSize)
widthRef.current = nextWidth
setWidth (nextWidth)
}, [])
useEffect (() => {
maxWidthRef.current = maxWidth
maxWidthRatioRef.current = maxWidthRatio
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
const updateSize = useCallback ((clientX: number, clientY: number) => {
const updateWidth = useCallback ((clientX: number) => {
const state = resizeRef.current
if (state == null)
return
const containerSize = getContainerSize ()
const delta = state.verticalPreview
? clientY - state.startY
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
const nextSize = getClampedSize (state.startSize + delta, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize])
const containerWidth = getContainerWidth ()
const delta = state.side === 'left'
? clientX - state.startX
: state.startX - clientX
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
@@ -232,10 +211,8 @@ const SidebarComponent: FC<Props> = (
const state = {
pointerId: ev.pointerId,
side,
startSize: sizeRef.current,
startX: ev.clientX,
startY: ev.clientY,
verticalPreview }
startWidth: widthRef.current,
startX: ev.clientX }
if (ev.pointerType === 'mouse')
{
@@ -243,7 +220,7 @@ const SidebarComponent: FC<Props> = (
return
}
pendingPressRef.current = state
pendingPressRef.current = { ...state, startY: ev.clientY }
clearLongPressTimer ()
longPressTimerRef.current = setTimeout (() => {
if (pendingPressRef.current != null)
@@ -257,7 +234,7 @@ const SidebarComponent: FC<Props> = (
if (resizeRef.current != null)
{
ev.preventDefault ()
updateSize (ev.clientX, ev.clientY)
updateWidth (ev.clientX)
return
}
@@ -285,58 +262,58 @@ const SidebarComponent: FC<Props> = (
finishResize ()
}
const resetSize = useCallback (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
applySize (nextSize)
window.localStorage.removeItem (
storageKeyForSidebar (sidebarKey, side, verticalPreview))
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const resetWidth = useCallback (() => {
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
applyWidth (nextWidth)
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
ev.preventDefault ()
ev.stopPropagation ()
resetSize ()
resetWidth ()
}
useEffect (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (
readStoredSidebarSize (sidebarKey, side, verticalPreview),
containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (
readStoredSidebarWidth (sidebarKey, side),
containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
useEffect (() => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
}, [applySize, getClampedSize, getContainerSize])
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (typeof window === 'undefined')
return
const handleResize = () => {
const containerSize = getContainerSize ()
const nextSize = getClampedSize (sizeRef.current, containerSize)
applySize (nextSize)
const containerWidth = getContainerWidth ()
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
applyWidth (nextWidth)
}
window.addEventListener ('resize', handleResize)
return () => window.removeEventListener ('resize', handleResize)
}, [applySize, getClampedSize, getContainerSize])
}, [applyWidth, getClampedWidth, getContainerWidth])
useEffect (() => {
if (!verticalPreview)
onWidthChange?.(size)
}, [onWidthChange, size, verticalPreview])
onWidthChange?.(width)
}, [onWidthChange, width])
useEffect (() => () => finishResize (), [finishResize, verticalPreview])
useEffect (() => () => {
clearLongPressTimer ()
restoreBodyStyle ()
}, [clearLongPressTimer, restoreBodyStyle])
const style = {
'--sidebar-width': `${ size }px`,
'--sidebar-height': `${ size }px` } as CSSProperties
'--sidebar-width': `${ width }px` } as CSSProperties
return (
<motion.div
@@ -345,7 +322,7 @@ const SidebarComponent: FC<Props> = (
transition={{ layout: layoutTransition }}
style={style}
className={cn (
'sidebar-panel relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
'relative w-full md:w-[var(--sidebar-width)] md:shrink-0 md:h-full',
className)}>
<Helmet>
<style>
@@ -368,16 +345,16 @@ const SidebarComponent: FC<Props> = (
<div
role="separator"
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
aria-orientation="vertical"
aria-label={message ('sidebarWidth').text}
lang={message ('sidebarWidth').language}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn (
'sidebar-resize hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'hidden md:block absolute top-0 bottom-0 z-20 w-4 cursor-col-resize',
'touch-none select-none',
side === 'left' ? '-right-2' : '-left-2',
'before:absolute before:top-0 before:bottom-0 before:w-px',
+4 -4
ファイルの表示
@@ -32,10 +32,10 @@ const buttonVariants = cva (
link:
'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300'},
size: {
default: '[block-size:2.5rem] [padding-inline:1rem] [padding-block:.5rem]',
sm: '[block-size:2.25rem] rounded-md [padding-inline:.75rem]',
lg: '[block-size:2.75rem] rounded-md [padding-inline:2rem]',
icon: '[block-size:2.5rem] [inline-size:2.5rem]'}},
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10'}},
defaultVariants: {
variant: 'default',
size: 'default'}})
+3 -5
ファイルの表示
@@ -60,14 +60,12 @@ const InheritDialogue: FC<Props> = ({ visible, onVisibleChange, setUser }) => {
return (
<Dialog open={visible} onOpenChange={onVisibleChange}>
<DialogContent className="inheritance-dialogue px-6 pb-6 pt-7"
closeLabel={label ('actions.close')}>
<DialogContent className="px-6 pp-6 pt-7" closeLabel={label ('actions.close')}>
<DialogHeader className="pl-8">
<DialogTitle>{label ('account.inherit')}</DialogTitle>
<DialogDescription asChild>
<div className="inheritance-dialogue-fields flex gap-2">
<Input className="inheritance-code"
placeholder={message ('transfer.placeholder').text}
<div className="flex gap-2">
<Input placeholder={message ('transfer.placeholder').text}
aria-label={message ('transfer.placeholder').text}
lang={message ('transfer.placeholder').language}
value={inputCode}
+1 -1
ファイルの表示
@@ -60,7 +60,7 @@ const UserCodeDialogue: FC<Props> = ({ visible, onVisibleChange, user, setUser }
<DialogDescription asChild>
<div>
<p>{label ('userCode.description')}</p>
<div className="inheritance-code m-2">{user?.inheritanceCode}</div>
<div className="m-2">{user?.inheritanceCode}</div>
<p className="mt-1 text-sm text-destructive">
{label ('userCode.secretWarning')}
</p>
-9
ファイルの表示
@@ -1,9 +0,0 @@
import { describe, expect, it } from 'vitest'
import { API_BASE_URL } from '@/config'
describe ('test API configuration', () => {
it ('resolves the sample configuration without a local config file', () => {
expect (API_BASE_URL).toBe ('/api')
})
})
+6 -11
ファイルの表示
@@ -9,8 +9,7 @@ import { createStaticHandler, createStaticRouter,
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
import { apiGet, isApiError } from '@/lib/api'
import { createUII18n } from '@/lib/i18n'
import { fetchLocales, isLocaleCode, isVerticalPreviewPath,
legacyLocalePath, uiLocaleFromPath } from '@/lib/locales'
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { withRenderContext } from '@/lib/renderContext.server'
@@ -73,16 +72,13 @@ export const render = async (
pathname => resolvePageRoute (pathname) != null)
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
const segment = pathname.split ('/')[1]
const verticalPreview = isVerticalPreviewPath (pathname)
const known = resolvePageRoute (pathname)
if (legacy != null && known != null)
return { status: 308, location: legacy }
const locale = isLocaleCode (segment) ? segment : 'ja'
const uiLocale = uiLocaleFromPath (pathname)
const routePrefix = verticalPreview ? 'mn-Mong' : locale
if (known?.route.handle?.redirectTo != null)
return { status: 308,
location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` }
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
const requestContext: RenderContext & { status: number } = {
pathname: url.pathname, origin, apiBaseURL,
...authentication, status: 200 }
@@ -110,15 +106,14 @@ export const render = async (
}
}
const prepare = known?.route.handle?.prepare
const routeLocaleValid = isLocaleCode (segment) || verticalPreview
let status = known == null || !active || !routeLocaleValid ? 404 : 200
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
if (status === 200 && known?.route.handle?.authentication != null
&& client.getQueryData (sessionKeys.current) == null)
status = 401
if (prepare != null && known != null && status === 200)
status = await prepare ({ client, params: known.params, locale,
searchParams: url.searchParams })
const instance = createUII18n (uiLocale)
const instance = createUII18n (locale)
const handler = createStaticHandler (appRoutes)
const context = await handler.query (new Request (url))
if (context instanceof Response)
@@ -188,11 +183,11 @@ export const render = async (
isAxiosError: true, response: {
status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } }
})) }
const bootstrap = JSON.stringify ({ state, status, locale: uiLocale,
const bootstrap = JSON.stringify ({ state, status, locale,
url: url.pathname + url.search })
.replace (/</g, '\\u003c').replace (/\u2028/g, '\\u2028')
.replace (/\u2029/g, '\\u2029')
return { status, html, head, locale: uiLocale, bootstrap,
return { status, html, head, locale, bootstrap,
setCookies: requestContext.setCookies }
}
finally
-840
ファイルの表示
@@ -625,843 +625,3 @@ body
white-space: nowrap;
will-change: transform;
}
/* Locally hosted Mongolian font; the OFL accompanies the font file. */
@font-face
{
font-family: 'BTRC Mongolian';
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Mongolian flows down each line, then into the next column on the right. */
:lang(mn-Mong)
{
font-family: 'BTRC Mongolian', 'Mongolian Baiti', 'Noto Sans JP Variable', sans-serif;
}
/* Include portalled dialogues and toasts, and controls with UA writing modes. */
html:lang(mn-Mong) body > :not(#root),
html:lang(mn-Mong) #root > :not(.app-layout),
html:lang(mn-Mong) :is(button, input, select, textarea),
[data-vertical-preview] :is(
.main-area:not(.posts-main-area, .tags-main-area, .wiki-main-area),
.sidebar > *, .tag-list-filter, .post-list > *,
.top-nav-brand-link, .top-nav-menu-link,
.top-nav-user-desktop, .top-nav-user-mobile, .top-nav-mobile-menu)
{
writing-mode: vertical-lr;
text-orientation: mixed;
}
/* Existing Japanese vertical decorations must also advance to the right. */
html:lang(mn-Mong) [style*='writing-mode']
{
writing-mode: vertical-lr !important;
}
/* Inline alignment turns left/right into top/bottom; flex cross alignment
and table vertical-align already follow the vertical writing mode. */
html:lang(mn-Mong) .text-left
{
text-align: start;
}
html:lang(mn-Mong) .text-right
{
text-align: end;
}
[data-vertical-preview]
{
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] > :not(.top-nav-layout)
{
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
[data-vertical-preview] [data-sidebar-container]
{
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
[data-vertical-preview] .main-area
{
min-width: 0;
min-height: 0;
overscroll-behavior: contain;
}
[data-vertical-preview] .main-area[data-vertical-content]
{
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
}
/* Specialised lists below retain their own grid and table layouts. */
[data-vertical-preview] .main-area[data-vertical-content]:not(
.posts-main-area, .tags-main-area, .wiki-main-area)
{
writing-mode: vertical-lr;
text-orientation: mixed;
text-align: start;
overflow-y: auto;
}
[data-vertical-preview] .main-area[data-vertical-content]:not(
.posts-main-area, .tags-main-area, .wiki-main-area) > :is(div, article, section)
{
max-width: none;
max-inline-size: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] .prose
{
max-width: none;
max-inline-size: 100%;
}
/* Input and data widgets retain nested scrolling in the vertical text flow. */
[data-vertical-preview] .main-area[data-vertical-content] :is(
form, .form-layout, pre, .overflow-x-auto, .rc-md-editor, .not-prose)
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 100%;
overflow: auto;
}
[data-vertical-preview] .main-area[data-vertical-content] :is(form, .form-layout)
{
width: auto;
inline-size: min(36rem, 100%);
}
[data-vertical-preview] .main-area[data-vertical-content] form.space-y-2
{
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .75rem;
}
[data-vertical-preview] .main-area[data-vertical-content] form.space-y-2 > *
{
flex: none;
margin: 0;
}
[data-vertical-preview] .main-area[data-vertical-content] form label.block
{
margin: 0;
margin-block-end: .25rem;
}
[data-vertical-preview] .main-area[data-vertical-content] form.space-y-2
> :has(> input[type='checkbox'])
{
display: flex;
align-items: center;
gap: .5rem;
}
[data-vertical-preview] .main-area[data-vertical-content] form.space-y-2
> :has(> input[type='checkbox']) > label
{
margin: 0;
}
[data-vertical-preview] :where(.main-area[data-vertical-content]) :is(
button, input, select, textarea, code, time, table)
{
writing-mode: vertical-lr;
}
[data-vertical-preview] .main-area[data-vertical-content] :is(img, video, iframe, canvas)
{
max-height: 100%;
object-fit: contain;
}
/* Keep the URL source horizontal even in the vertical page flow. */
[data-vertical-preview] .main-area[data-vertical-content] .post-import-source textarea
{
writing-mode: horizontal-tb;
width: min(36rem, 65vw);
height: min(32rem, 70dvh);
inline-size: min(36rem, 65vw);
block-size: min(32rem, 70dvh);
}
[data-vertical-preview] .main-area .post-search-form
{
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
inline-size: 100%;
}
[data-vertical-preview] .post-search-form > :not([hidden])
{
flex: none;
margin: 0;
width: auto;
}
[data-vertical-preview] .post-search-form label.block
{
margin: 0;
margin-block-end: .5rem;
}
[data-vertical-preview] .post-search-form input[type='text']
{
width: auto;
inline-size: min(24rem, 100%);
}
[data-vertical-preview] .post-search-form fieldset
{
width: auto;
margin: 0;
}
[data-vertical-preview] .post-search-form input[type='datetime-local']
{
inline-size: 13rem;
}
[data-vertical-preview] .main-area.post-import-review
{
padding-bottom: 1rem;
}
[data-vertical-preview] .post-import-review :is(.space-y-6, .space-y-3) > *
{
margin-top: 0;
margin-bottom: 0;
margin-block-end: 1rem;
}
[data-vertical-preview] > .post-import-footer
{
flex: none;
writing-mode: vertical-lr;
overflow: auto;
border-top: 0;
border-left-width: 1px;
}
[data-vertical-preview] .post-import-footer > div
{
height: 100%;
max-width: none;
flex-direction: column;
align-items: stretch;
}
[data-vertical-preview] .post-import-footer > div > :first-child
{
align-items: flex-start;
}
[data-vertical-preview] .post-import-footer > div > :last-child
{
flex-direction: row-reverse;
justify-content: space-between;
}
[data-vertical-preview] .post-import-footer button
{
width: auto;
}
[data-vertical-preview] .tag-tree-arrow
{
display: inline-block;
writing-mode: horizontal-tb;
transform: rotate(-90deg) scaleX(-1);
}
[data-vertical-preview] .sidebar-panel
{
width: 100%;
height: var(--sidebar-height);
min-height: 0;
flex: none;
}
/* Mirror the scroll box and its contents to put its scrollbar above the tags. */
[data-vertical-preview] .sidebar
{
display: flex;
align-items: flex-end;
gap: 1rem;
direction: ltr;
transform: scaleY(-1);
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior: contain;
}
[data-vertical-preview] .sidebar > *
{
flex-shrink: 0;
transform: scaleY(-1);
}
[data-vertical-preview] .sidebar > form
{
writing-mode: vertical-lr;
text-orientation: mixed;
display: block;
width: auto;
height: 100%;
}
[data-vertical-preview] .sidebar > form > input[type='text']
{
writing-mode: vertical-lr;
text-orientation: mixed;
width: auto;
height: 100%;
min-height: 0;
padding: .75rem .5rem;
}
[data-vertical-preview] .sidebar > form > .tag-search-suggestions
{
inset: auto;
inset-inline-start: 0;
inset-block-start: 100%;
width: auto;
inline-size: 100%;
max-block-size: min(16rem, 75vw);
margin: 0;
overflow: auto;
}
[data-vertical-preview] .sidebar > div
{
writing-mode: vertical-lr;
text-orientation: mixed;
height: 100%;
margin: 0;
}
[data-vertical-preview] .sidebar :is(h2, h3, ul)
{
margin: 0;
margin-block-end: 1rem;
}
[data-vertical-preview] .sidebar-resize
{
display: block;
inset: auto 0 -.5rem;
width: 100%;
height: 1rem;
cursor: row-resize;
}
[data-vertical-preview] .sidebar-resize::before
{
inset: 50% 0 auto;
width: 100%;
height: 1px;
}
[data-vertical-preview] .top-nav-locale-name:has(:lang(mn-Mong))
{
display: inline;
writing-mode: vertical-lr;
text-orientation: mixed;
font-size: 1.25rem;
line-height: 1.5;
flex-shrink: 0;
}
[data-vertical-preview] .top-nav-menu-link:has(.top-nav-locale-name)
{
height: auto;
min-height: 2rem;
}
/* The post grid fills each column downwards before continuing to the right. */
[data-vertical-preview] .posts-main-area > div
{
display: flex;
height: 100%;
width: max-content;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .posts-main-area > div > div:first-child
{
flex: none;
flex-direction: row;
justify-content: space-between;
height: 100%;
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .posts-main-area > div > div:last-child
{
display: flex;
width: max-content;
height: 100%;
margin: 0;
gap: 1rem;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list
{
display: grid;
grid-auto-flow: column;
grid-auto-columns: 10rem;
grid-template-rows: repeat(auto-fill, min(10rem, 100%));
flex: none;
width: max-content;
height: 100%;
writing-mode: horizontal-tb;
}
[data-vertical-preview] .main-area[data-vertical-content] .post-list > a
{
height: 100%;
}
[data-vertical-preview] .main-area[data-vertical-content] nav
{
flex: none;
align-self: flex-start;
margin: 0;
writing-mode: vertical-lr;
}
[data-vertical-preview] .main-area[data-vertical-content] .pagination
{
align-self: stretch;
inline-size: 100%;
}
[data-vertical-preview] .tags-main-area
{
display: flex;
align-items: stretch;
gap: 1.5rem;
}
[data-vertical-preview] .tag-list-filter
{
flex: none;
width: auto;
inline-size: 100%;
max-inline-size: none;
overflow: visible;
}
[data-vertical-preview] .tag-list-filter > div
{
align-items: flex-start;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 1rem;
}
[data-vertical-preview] .main-area .tag-list-filter form.space-y-2
{
inline-size: auto;
max-height: none;
overflow: visible;
}
[data-vertical-preview] .tag-list-filter input[type='datetime-local']
{
inline-size: 13rem;
}
@media (min-width: 768px)
{
[data-vertical-preview] .tag-list-filter form > div
{
white-space: nowrap;
}
}
html:lang(mn-Mong) .inheritance-code
{
writing-mode: horizontal-tb;
}
html:lang(mn-Mong) .inheritance-dialogue
{
width: auto;
max-width: calc(100vw - 2rem);
inline-size: min(32rem, calc(100dvh - 2rem));
block-size: fit-content;
}
html:lang(mn-Mong) .inheritance-dialogue-fields
{
writing-mode: horizontal-tb;
flex-direction: column;
justify-content: space-between;
width: min(16rem, calc(100vw - 8rem));
height: 100%;
min-height: 0;
}
html:lang(mn-Mong) .inheritance-dialogue-fields > button
{
align-self: flex-end;
}
[data-vertical-preview] .tag-list-filter h1
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: 8rem;
margin-block: 0;
}
[data-vertical-preview] .tag-list-filter input[type='number']
{
max-inline-size: 7rem;
}
[data-vertical-preview] .tag-list-results
{
display: flex;
flex: none;
gap: 1rem;
max-width: none;
margin: 0;
}
[data-vertical-preview] .tag-list-table
{
max-width: none;
overflow-x: visible;
overflow-y: auto;
}
[data-vertical-preview] .tag-list-table table
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: max-content;
block-size: max-content;
min-width: 0;
}
[data-vertical-preview] .tag-list-table col
{
width: auto;
}
[data-vertical-preview] .tag-list-table :is(th, td)
{
min-inline-size: 2rem;
max-inline-size: 16rem;
}
[data-vertical-preview] .tag-list-table thead
{
border-bottom: 0;
border-block-end: 2px solid currentColor;
}
[data-vertical-preview] .tag-list-table td:nth-child(2),
[data-vertical-preview] .tag-list-table time
{
writing-mode: vertical-lr;
white-space: nowrap;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee,
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__static
{
white-space: normal;
overflow: visible;
text-overflow: clip;
max-inline-size: 16rem;
opacity: 1;
}
[data-vertical-preview] :is(.sidebar, .tag-list-table, .wiki-main-area) .tag-marquee__animated
{
display: none;
}
/* Prose flows through the viewport height and grows into columns on the right. */
[data-vertical-preview] .wiki-main-area
{
display: flex;
gap: 1rem;
}
[data-vertical-preview] .wiki-main-area > div
{
flex: none;
writing-mode: vertical-lr;
text-orientation: mixed;
margin: 0;
}
[data-vertical-preview] .wiki-main-area > div > span
{
writing-mode: vertical-lr;
}
[data-vertical-preview] .wiki-main-area article,
[data-vertical-preview] .posts-main-area .localised-document
{
writing-mode: vertical-lr;
text-orientation: mixed;
inline-size: 100%;
block-size: max-content;
max-width: none;
margin: 0;
flex: none;
}
[data-vertical-preview] .main-area[data-vertical-content] .localised-document
{
max-width: none;
overflow: visible;
}
[data-vertical-preview] .wiki-main-area article :is(h1, h2, h3, h4, p, ul, ol, blockquote)
{
margin: 0;
margin-block: 1em;
}
[data-vertical-preview] .wiki-main-area article :is(ul, ol)
{
padding: 0;
padding-inline-start: 1.5em;
}
[data-vertical-preview] .wiki-main-area article :is(img, video, iframe, svg)
{
writing-mode: horizontal-tb;
max-height: 100%;
max-width: none;
object-fit: contain;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose),
[data-vertical-preview] .main-area[data-vertical-content] :is(code, time, input, select, textarea)
{
writing-mode: vertical-lr;
text-orientation: mixed;
}
[data-vertical-preview] .wiki-main-area article :is(pre, table, .not-prose)
{
display: inline-block;
max-height: 100%;
max-width: min(48rem, 80vw);
overflow: auto;
vertical-align: top;
}
[data-vertical-preview] .wiki-main-area article .not-prose
{
width: max-content;
height: 100%;
}
[data-vertical-preview] .wiki-main-area .not-prose > div
{
display: flex;
flex-direction: column;
height: 100%;
margin: 0;
}
[data-vertical-preview] .wiki-main-area .not-prose > div > div:last-child
{
flex: 1;
min-height: 0;
}
@media (min-width: 768px)
{
[data-vertical-preview].app-layout
{
flex-direction: row;
}
[data-vertical-preview] .top-nav-layout
{
display: flex;
width: max-content;
height: 100%;
}
[data-vertical-preview] .top-nav-bar
{
flex-direction: column;
flex: none;
width: 3.5rem;
height: 100%;
padding: .5rem 0;
gap: .5rem;
}
[data-vertical-preview] .top-nav-primary
{
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
height: auto;
overflow: hidden;
}
[data-vertical-preview] .top-nav-brand-link
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: none;
white-space: nowrap;
margin-block: .5rem;
}
[data-vertical-preview] .top-nav-desktop-menu
{
flex-direction: column;
width: 100%;
height: auto;
}
[data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-primary,
[data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-desktop-menu,
[data-vertical-preview] .top-nav-layout[data-measuring] .top-nav-account
{
flex: 0 0 auto;
width: 100%;
height: max-content;
max-height: none;
}
[data-vertical-preview] .top-nav-desktop-menu > div:not(.top-nav-highlight)
{
width: 100%;
}
[data-vertical-preview] .top-nav-desktop-menu .top-nav-menu-link
{
justify-content: center;
width: 100%;
min-height: 3rem;
height: auto;
padding: .5rem;
}
[data-vertical-preview] .top-nav-menu-link > :not(svg, select),
[data-vertical-preview] .top-nav-more-menu h2
{
writing-mode: vertical-lr;
text-orientation: mixed;
max-height: none;
white-space: nowrap;
}
[data-vertical-preview] .top-nav-highlight
{
inset-inline-start: 0;
}
[data-vertical-preview] .top-nav-account
{
flex-direction: column;
width: 100%;
margin: 0;
}
[data-vertical-preview] .top-nav-account .top-nav-user-desktop
{
max-width: 100%;
margin: 0;
padding: 0;
}
[data-vertical-preview] .top-nav-locale-name
{
display: inline;
writing-mode: vertical-lr;
}
[data-vertical-preview] .top-nav-desktop-submenu
{
min-width: 0;
height: 100%;
}
[data-vertical-preview] .top-nav-submenu-content
{
writing-mode: vertical-lr;
text-orientation: mixed;
width: max-content;
height: 100%;
overflow: visible;
}
[data-vertical-preview] .top-nav-submenu-content:not(.top-nav-more-menu) > div
{
height: 100%;
}
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
{
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
max-height: 100%;
padding-inline: .75rem;
padding-block: 0;
}
[data-vertical-preview] .top-nav-desktop-submenu .top-nav-menu-link
{
min-height: 0;
min-width: 40px;
padding-inline: .75rem;
padding-block: .5rem;
}
[data-vertical-preview] .top-nav-submenu-items .top-nav-menu-link
{
justify-content: center;
}
}
@media (max-width: 767px)
{
[data-vertical-preview] .top-nav-layout
{
max-height: 50dvh;
overflow-y: auto;
}
}
+4 -11
ファイルの表示
@@ -1,11 +1,9 @@
import { createInstance } from 'i18next'
import { initReactI18next } from 'react-i18next'
import { MONGOLIAN_LOCALE, SUPPORTED_LOCALES } from '@/lib/locales'
import type { i18n as I18n, TOptions } from 'i18next'
import type { UILocaleCode } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
// Keep Japanese placeholders editable without reporting them as completed translations.
const translatedResource = (resource: Record<string, unknown>): Record<string, unknown> => {
@@ -36,14 +34,9 @@ for (const [path, dictionary] of Object.entries (dictionaries))
const [, , locale, filename] = path.split ('/')
resources[locale] ??= { }
resources[locale][filename.slice (0, -5)] =
locale === 'ja' ? dictionary : translatedResource (dictionary)
locale === 'ain-Kana' ? translatedResource (dictionary) : dictionary
}
const namespaces = Object.keys (resources.ja ?? { })
// No Mongolian translations yet; preserve the existing fallback language metadata.
resources[MONGOLIAN_LOCALE.code] ??= { }
for (const namespace of namespaces)
resources[MONGOLIAN_LOCALE.code][namespace] ??=
translatedResource ({ _untranslated: ['*'] })
export const createUII18n = (locale: string): I18n => {
@@ -51,7 +44,7 @@ export const createUII18n = (locale: string): I18n => {
void instance.init ({
lng: locale,
fallbackLng: 'ja',
supportedLngs: [...SUPPORTED_LOCALES, MONGOLIAN_LOCALE.code],
supportedLngs: ['ja', 'ain-Kana'],
load: 'currentOnly',
initAsync: false,
defaultNS: 'navigation',
@@ -129,7 +122,7 @@ export const languageUIMessage = (
export const untranslatedKeys = (
locale: UILocaleCode,
locale: LocaleCode,
namespace = 'navigation',
): string[] => {
const collect = (resource: Record<string, unknown>, prefix = ''): string[] =>
+6 -26
ファイルの表示
@@ -7,10 +7,6 @@ export const SUPPORTED_LOCALES = ['ja', 'ain-Kana'] as const
export type LocaleCode = typeof SUPPORTED_LOCALES[number]
// This UI locale uses Japanese content until the API supports Mongolian.
export const MONGOLIAN_LOCALE = { code: 'mn-Mong', name: 'ᠮᠣᠩᠭᠣᠯ' } as const
export type UILocaleCode = LocaleCode | typeof MONGOLIAN_LOCALE.code
export type AvailableLocale = { code: string; name: string }
@@ -32,19 +28,6 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
}
// Frontend layout selection must not change the API's content locale.
export const isVerticalPreviewPath = (pathname: string): boolean =>
/^\/mn-Mong(?:[/?#]|$)/.test (pathname)
export const isUILocaleCode = (value: unknown): value is UILocaleCode =>
value === MONGOLIAN_LOCALE.code || isLocaleCode (value)
export const uiLocaleFromPath = (pathname: string): UILocaleCode =>
isVerticalPreviewPath (pathname) ? MONGOLIAN_LOCALE.code : localeFromPath (pathname) ?? 'ja'
export const resolveViewingLocale = (
pathname: string,
routeLocale?: unknown,
@@ -57,29 +40,27 @@ export const currentViewingLocale = (): LocaleCode =>
export const unlocalisedPath = (pathname: string): string => {
const locale = isVerticalPreviewPath (pathname) ? 'mn-Mong' : localeFromPath (pathname)
const locale = localeFromPath (pathname)
return locale == null ? pathname : pathname.slice (locale.length + 1) || '/'
}
export const localiseTo = (to: To, locale: LocaleCode, viewingPath = ''): To => {
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
|| isVerticalPreviewPath (pathname))
|| localeFromPath (pathname) != null)
return to
const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale
const localised = `/${ prefix }${ pathname }`
const localised = `/${ locale }${ pathname }`
return typeof to === 'string' ? localised : { ...to, pathname: localised }
}
export const switchLocalePath = (
location: { pathname: string; search: string; hash: string },
locale: UILocaleCode,
locale: LocaleCode,
): string =>
`/${ locale }${ unlocalisedPath (location.pathname) }${ location.search }${ location.hash }`
@@ -92,8 +73,7 @@ export const legacyLocalePath = (
location: { pathname: string; search: string; hash: string },
isPage: (pathname: string) => boolean = () => false,
): string | null => {
if (localeFromPath (location.pathname) != null
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
return null
return switchLocalePath (location, automaticallySelectedLocale ())
}
+3 -3
ファイルの表示
@@ -1,19 +1,19 @@
import { translateUIMessage } from '@/lib/i18n'
import type navigation from '@/locales/ja/navigation.json'
import type { UILocaleCode } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
export type UIMessageKey = keyof typeof navigation
export const uiMessage = (
locale: UILocaleCode,
locale: LocaleCode,
key: UIMessageKey,
values: Record<string, string | number> = { },
): string => translateUIMessage (key, 'navigation', locale, values).text
export const uiMessageLanguage = (locale: UILocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
export const uiMessageLanguage = (locale: LocaleCode, key: UIMessageKey): 'ja' | 'ain' =>
translateUIMessage (key, 'navigation', locale).language === 'ain-Kana'
? 'ain'
: 'ja'
+4 -14
ファイルの表示
@@ -1,12 +1,11 @@
import { useCallback } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { isVerticalPreviewPath, localiseTo,
resolveViewingLocale, uiLocaleFromPath } from '@/lib/locales'
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
import type { LocaleCode, UILocaleCode } from '@/lib/locales'
import type { LocaleCode } from '@/lib/locales'
export const useLocale = (): LocaleCode => {
@@ -16,22 +15,13 @@ export const useLocale = (): LocaleCode => {
}
export const useUILocale = (): UILocaleCode =>
uiLocaleFromPath (useLocation ().pathname)
export const useLocaleNavigate = (): NavigateFunction => {
const navigate = useNavigate ()
const locale = useLocale ()
const { pathname } = useLocation ()
return useCallback ((to: To | number, options?: NavigateOptions) => {
if (typeof to === 'number')
navigate (to)
else
navigate (localiseTo (to, locale, pathname), options)
}, [locale, navigate, pathname])
navigate (localiseTo (to, locale), options)
}, [locale, navigate])
}
export const useVerticalPreview = (): boolean =>
isVerticalPreviewPath (useLocation ().pathname)
+2 -2
ファイルの表示
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next'
import LocalisedText from '@/components/LocalisedText'
import { translateUIMessage } from '@/lib/i18n'
import { useUILocale } from '@/lib/useLocale'
import { useLocale } from '@/lib/useLocale'
export const useUITranslation = (namespace = 'common') => {
const locale = useUILocale ()
const locale = useLocale ()
const { i18n } = useTranslation (namespace, { lng: locale })
const message = useCallback ((key: string, values?: Record<string, string | number>) => {
+1 -1
ファイルの表示
@@ -152,7 +152,7 @@ export const msToTime = (ms: number): string => {
export const inputClass = (invalid?: boolean, className?: string): string =>
cn ('[inline-size:100%] rounded border p-2',
cn ('w-full rounded border p-2',
(invalid
? ['border-red-500 bg-red-50 text-red-900',
'placeholder:text-red-300',
+2 -2
ファイルの表示
@@ -6,7 +6,7 @@ import { I18nextProvider } from 'react-i18next'
import '@/index.css'
import App, { preloadPageRoute, resolvePageRoute } from '@/App'
import { createUII18n } from '@/lib/i18n'
import { currentViewingLocale, uiLocaleFromPath } from '@/lib/locales'
import { currentViewingLocale } from '@/lib/locales'
import { localesKeys } from '@/lib/queryKeys'
import { applyClientAnimationMode, getClientAnimationMode } from '@/lib/settings'
@@ -22,7 +22,7 @@ const client = new QueryClient ({
const mount = (serverRendered: boolean) => {
const instance = createUII18n (uiLocaleFromPath (window.location.pathname))
const instance = createUII18n (currentViewingLocale ())
const app = (
<HelmetProvider context={helmetContext}>
<QueryClientProvider client={client}>
+1 -2
ファイルの表示
@@ -359,8 +359,7 @@ const CountsResourceContent = (
</ul>
</nav>
</SidebarComponent>
<MainArea animateLayout={false} verticalContent={false}
className="flex min-h-0 min-w-0 flex-col p-2">
<MainArea animateLayout={false} className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base" lang={title.language}>{title.text}</PageTitle>
<div ref={controlsArea} className="shrink-0"
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
+1 -1
ファイルの表示
@@ -107,7 +107,7 @@ const PostDetailPage: FC<Props> = ({ user }) => {
{post && <TagDetailSidebar post={post} className="hidden md:block"/>}
<MainArea className="relative" verticalContent={false}>
<MainArea className="relative">
{post
? (
<>
+3 -4
ファイルの表示
@@ -1061,7 +1061,7 @@ const PostImportReviewPage: FC<Props> = ({ user }) => {
<>
<PageHead title={message ('review.title')}/>
<MainArea className="post-import-review pb-40">
<MainArea className="pb-40">
<div className="mx-auto max-w-6xl space-y-6">
<PageTitle>{label ('review.title')}</PageTitle>
@@ -1164,9 +1164,8 @@ const PostImportFooter = (
const { label } = useUITranslation ('imports')
return (
<div
className="post-import-footer shrink-0 border-t bg-white/95 p-4 backdrop-blur
text-neutral-950 dark:border-neutral-700 dark:bg-neutral-950/95
dark:text-neutral-50">
className="shrink-0 border-t bg-white/95 p-4 backdrop-blur
dark:border-neutral-700 dark:bg-neutral-950/95">
<div className="mx-auto flex max-w-6xl flex-col gap-3 md:flex-row
md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
+1 -1
ファイルの表示
@@ -130,7 +130,7 @@ const PostImportSourcePage: FC<Props> = ({ user }) => {
<MainArea>
<PageHead title={uiMessage ('source.title')}/>
<Form className="post-import-source max-w-4xl">
<Form className="max-w-4xl">
<PageTitle>{label ('source.title')}</PageTitle>
<FormField label={label ('source.urls')}>
{() => (
+1 -1
ファイルの表示
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
JSON.stringify (statesToSave))
}}/>
<MainArea className="posts-main-area" verticalContent>
<MainArea>
<TabGroup
rightAddon={
<label className="flex items-center gap-2 text-xs text-muted-foreground">
+1 -1
ファイルの表示
@@ -250,7 +250,7 @@ const PostSearchPage: FC = () => {
</label>
</div>
<form onSubmit={handleSearch} className="post-search-form space-y-2">
<form onSubmit={handleSearch} className="space-y-2">
{/* タイトル */}
<FormField label={label ('shared:labels.title')}>
{({ invalid }) => (
+4 -4
ファイルの表示
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
}), [page, totalPages, location.search]))
return (
<MainArea className="tags-main-area min-w-0" verticalContent>
<MainArea className="min-w-0">
<PageHead title={message ('title')}/>
<div className="tag-list-filter max-w-xl">
<div className="max-w-xl">
<div className="flex flex-wrap items-center justify-between gap-3">
<PageTitle>{label ('title')}</PageTitle>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -358,8 +358,8 @@ const TagListPage: FC = () => {
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{loading ? label ('common:loading') : (results.length > 0 ? (
<div className="tag-list-results mt-4 min-w-0 max-w-full">
<div className="tag-list-table min-w-0 max-w-full overflow-x-auto">
<div className="mt-4 min-w-0 max-w-full">
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="w-full min-w-[2000px] table-auto border-collapse">
<colgroup>
<col className="w-72"/>
+1 -1
ファイルの表示
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
return <NotFound/>
return (
<MainArea className="wiki-main-area" verticalContent>
<MainArea>
<PageHead title={{
text: `${displayTitle}${deprecated
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
+1 -2
ファイルの表示
@@ -10,8 +10,7 @@ type Transform = (source: string, id: string) => Promise<{ code: string } | null
export const transformMdx = async (source: string, suffix = '.mdx') => {
const config = viteConfig ({ command: 'serve', mode: 'test' })
const plugins = config.plugins?.flat () as { name: string; transform: Transform }[]
const plugins = viteConfig.plugins?.flat () as { name: string; transform: Transform }[]
const plugin = plugins.find (entry => entry.name === '@mdx-js/rollup')
if (plugin == null)
throw new Error ('The application MDX compiler is not registered')
-41
ファイルの表示
@@ -1,41 +0,0 @@
import { execFileSync } from 'node:child_process'
import { resolveConfig, transformWithEsbuild } from 'vite'
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest'
import viteConfig from './vite.config'
vi.hoisted (() => {
vi.stubGlobal ('Uint8Array', Object.getPrototypeOf (new TextEncoder ().encode ('')).constructor)
})
afterAll (() => vi.unstubAllGlobals ())
afterEach (() => vi.unstubAllEnvs ())
describe ('JSX runtime configuration', () => {
it.each (['development', 'test', 'production']) (
'builds JSX compatible with production React under NODE_ENV=%s',
async environment => {
vi.stubEnv ('NODE_ENV', environment)
const config = await resolveConfig ({
...viteConfig ({ command: 'build', mode: 'production' }),
configFile: false, build: { ssr: true } }, 'build')
if (config.esbuild === false) throw new Error ('JSX transformation disabled')
const { code } = await transformWithEsbuild (
'export default <div>SSR</div>', 'runtime.tsx', {
jsx: config.esbuild.jsx,
jsxDev: config.esbuild.jsxDev,
jsxImportSource: config.esbuild.jsxImportSource })
expect (code).toContain ('react/jsx-runtime')
expect (code).not.toContain ('jsxDEV')
execFileSync (process.execPath, ['--input-type=module', '-e', code], {
cwd: import.meta.dirname,
env: { ...process.env, NODE_ENV: 'production' } })
})
it ('preserves development JSX for the development server', async () => {
vi.stubEnv ('NODE_ENV', 'development')
const config = await resolveConfig ({
...viteConfig ({ command: 'serve', mode: 'development' }),
configFile: false }, 'serve')
expect (config.esbuild && config.esbuild.jsxDev).toBe (true)
})
})
+2 -3
ファイルの表示
@@ -21,8 +21,7 @@ if (typeof originalTransform === 'function')
}
}
export default defineConfig (({ command }) => ({
esbuild: command === 'build' ? { jsxDev: false } : undefined,
export default defineConfig ({
plugins: [{ name: 'request-render-context', enforce: 'pre',
resolveId (id, _importer, options) {
const contextPath = path.resolve (__dirname, './src/lib/renderContext')
@@ -41,4 +40,4 @@ export default defineConfig (({ command }) => ({
rewrite: requestPath => requestPath.replace (/^\/api/, ''),
secure: false } },
watch: { usePolling: true,
interval: 100 } } }))
interval: 100 } } })
+4 -7
ファイルの表示
@@ -1,15 +1,12 @@
import path from 'node:path'
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default defineConfig (configEnv => mergeConfig (
viteConfig (configEnv),
{
test: { alias: {
'@/config': path.resolve (__dirname, './src/config.sample.ts') },
environment: 'jsdom',
export default mergeConfig (
viteConfig,
defineConfig ({
test: { environment: 'jsdom',
testTimeout: 30_000,
exclude: ['**/node_modules/**', '**/dist/**', 'server.ssr.test.mjs'],
setupFiles: './src/test/setup.ts',