コミットを比較
2
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
d6fc57a00e | ||
|
|
dda3d87073 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLinkedTagIdsToNicoTagVersions < ActiveRecord::Migration[8.0]
|
||||
def change
|
||||
add_column :nico_tag_versions, :linked_tag_ids, :text
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' => '入力内容を確認してください.')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 列から復元できず、
|
||||
現在の名称からの推定変換も行わない。
|
||||
|
||||
@@ -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 ()],
|
||||
}
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
バイナリファイルは表示されません。
@@ -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,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 ()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'}})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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[] =>
|
||||
|
||||
@@ -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 ())
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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
|
||||
? (
|
||||
<>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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')}>
|
||||
{() => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 } } })
|
||||
|
||||
@@ -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',
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする