コミットを比較
3
コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
|
|
b9d7c4fef5 | ||
|
|
10b3717886 | ||
|
|
9e803d473a |
@@ -4,7 +4,7 @@ require 'digest'
|
|||||||
class HistoryAccess < ActiveSupport::CurrentAttributes
|
class HistoryAccess < ActiveSupport::CurrentAttributes
|
||||||
attribute :user
|
attribute :user
|
||||||
class Forbidden < StandardError; end
|
class Forbidden < StandardError; end
|
||||||
ARTIFACT_POLICY = 'counts-masked-summary-1'.freeze
|
ARTIFACT_POLICY = 'counts-private-predicates-4'.freeze
|
||||||
|
|
||||||
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
KEYS = { 'post_versions' => ['post_id'], 'tag_versions' => ['tag_id'],
|
||||||
'nico_tag_versions' => ['tag_id'],
|
'nico_tag_versions' => ['tag_id'],
|
||||||
|
|||||||
@@ -1,12 +1,41 @@
|
|||||||
class CountsAxes
|
class CountsAxes
|
||||||
MAX_AXES = 8
|
MAX_AXES = 8
|
||||||
|
MASKED_TEXT = '********'.freeze
|
||||||
MASKED_VALUE = '__counts_private__'.freeze
|
MASKED_VALUE = '__counts_private__'.freeze
|
||||||
PUBLIC_FIELDS = ['created_at', 'event_type'].freeze
|
PUBLIC_FIELDS = ['resource_id', 'version_no', 'event_type', 'user',
|
||||||
|
'created_at'].freeze
|
||||||
class TooManyAxes < ArgumentError; end
|
class TooManyAxes < ArgumentError; end
|
||||||
class PrivateCondition < ArgumentError; end
|
class PrivateCondition < ArgumentError; end
|
||||||
|
|
||||||
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
GRAINS = ['year', 'quarter', 'month', 'week', 'day', 'hour'].freeze
|
||||||
|
|
||||||
|
def self.private_field?(field)
|
||||||
|
!PUBLIC_FIELDS.include?(field) && field != 'user.id'
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.visible_ids_sql(definition)
|
||||||
|
"SELECT counts_visible.id FROM #{ definition.table } counts_visible WHERE " +
|
||||||
|
HistoryAccess.sql(definition.model, 'counts_visible')
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.visibility_sql(definition, field)
|
||||||
|
return 'TRUE' if HistoryAccess.admin?
|
||||||
|
|
||||||
|
source = visible_ids_sql(definition)
|
||||||
|
visible = "v.id IN (#{ source })"
|
||||||
|
return visible unless field == 'changed_field'
|
||||||
|
|
||||||
|
"(#{ visible }) AND (previous.id IS NULL OR previous.id IN (#{ source }))"
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.masked_sql(expression, definition, field)
|
||||||
|
return expression if HistoryAccess.admin? || !private_field?(field)
|
||||||
|
|
||||||
|
visible = visibility_sql(definition, field)
|
||||||
|
quoted = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||||
|
"CASE WHEN #{ visible } THEN #{ expression } ELSE #{ quoted } END"
|
||||||
|
end
|
||||||
|
|
||||||
def self.time_expression(expression, grain, timezone)
|
def self.time_expression(expression, grain, timezone)
|
||||||
raise ArgumentError unless GRAINS.include?(grain)
|
raise ArgumentError unless GRAINS.include?(grain)
|
||||||
|
|
||||||
@@ -60,13 +89,9 @@ class CountsAxes
|
|||||||
@definition.fields[field] == :datetime ? self.class.time_expression(
|
@definition.fields[field] == :datetime ? self.class.time_expression(
|
||||||
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
expression, axis.fetch('grain', 'month'), @population.timezone) : expression
|
||||||
end
|
end
|
||||||
if !HistoryAccess.admin? && !PUBLIC_FIELDS.include?(field)
|
if !HistoryAccess.admin? && self.class.private_field?(field)
|
||||||
visible = HistoryAccess.sql(@definition.model, 'v')
|
expression = self.class.masked_sql(
|
||||||
if field == 'changed_field'
|
"CAST((#{ expression }) AS CHAR)", @definition, field)
|
||||||
visible = "(#{ visible }) AND (#{ HistoryAccess.sql(@definition.model, 'previous') })"
|
|
||||||
end
|
|
||||||
expression = "CASE WHEN #{ visible } " +
|
|
||||||
"THEN CAST((#{ expression }) AS CHAR) ELSE '#{ MASKED_VALUE }' END"
|
|
||||||
end
|
end
|
||||||
expression
|
expression
|
||||||
end
|
end
|
||||||
@@ -77,10 +102,12 @@ class CountsAxes
|
|||||||
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
selected = indexes.map { "#{ expressions[_1] } AS a#{ _1 }" }
|
||||||
identities = ['v.id']
|
identities = ['v.id']
|
||||||
if rows.any? { _1['field'] == 'tags' }
|
if rows.any? { _1['field'] == 'tags' }
|
||||||
# Raw identities stay inside COUNT; masked tag occurrences remain additive.
|
identities << self.class.masked_sql(
|
||||||
identities << "COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
"COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
"CONCAT('external:', tag_entry.external_tag_id))"
|
"CONCAT('external:', tag_entry.external_tag_id))", @definition, 'tags')
|
||||||
identities << 'changed.field_key' if rows.any? { _1['field'] == 'changed_field' }
|
if rows.any? { _1['field'] == 'changed_field' }
|
||||||
|
identities << self.class.masked_sql('changed.field_key', @definition, 'changed_field')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
members = identities.each_index.map { "counts_member_#{ _1 }" }
|
||||||
selected += identities.each_with_index.map { |expression, index|
|
selected += identities.each_with_index.map { |expression, index|
|
||||||
@@ -98,8 +125,17 @@ class CountsAxes
|
|||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
result = ''
|
result = ''
|
||||||
if fields.include?('tags')
|
if fields.include?('tags')
|
||||||
|
document = 'CAST(v.tags_json AS JSON)'
|
||||||
|
unless HistoryAccess.admin?
|
||||||
|
visible = self.class.visible_ids_sql(@definition)
|
||||||
|
result += " LEFT JOIN (#{ visible }) counts_tags_visible " +
|
||||||
|
'ON counts_tags_visible.id = v.id '
|
||||||
|
document = "CASE WHEN counts_tags_visible.id IS NOT NULL " +
|
||||||
|
"THEN #{ document } ELSE JSON_ARRAY(" +
|
||||||
|
"JSON_OBJECT('tag_id', 0)) END"
|
||||||
|
end
|
||||||
result += <<~SQL.squish
|
result += <<~SQL.squish
|
||||||
JOIN JSON_TABLE(CAST(v.tags_json AS JSON), '$[*]' COLUMNS (
|
JOIN JSON_TABLE(#{ document }, '$[*]' COLUMNS (
|
||||||
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
tag_id BIGINT PATH '$.tag_id' NULL ON EMPTY,
|
||||||
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
external_tag_id BIGINT PATH '$.external_tag_id' NULL ON EMPTY)) tag_entry
|
||||||
|
|
||||||
@@ -112,6 +148,13 @@ class CountsAxes
|
|||||||
condition = @definition.changes.map do |field|
|
condition = @definition.changes.map do |field|
|
||||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||||
end.join(' OR ')
|
end.join(' OR ')
|
||||||
|
unless HistoryAccess.admin?
|
||||||
|
visible = self.class.visibility_sql(@definition, 'changed_field')
|
||||||
|
marker = ApplicationRecord.connection.quote(MASKED_VALUE)
|
||||||
|
fields += " UNION ALL SELECT #{ marker } AS field_key"
|
||||||
|
condition = "((#{ visible }) AND (#{ condition })) OR " +
|
||||||
|
"(NOT (#{ visible }) AND changed.field_key = #{ marker })"
|
||||||
|
end
|
||||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -82,14 +82,32 @@ class CountsCondition
|
|||||||
|
|
||||||
def apply(scope, entry)
|
def apply(scope, entry)
|
||||||
raise ArgumentError unless entry.is_a?(Hash)
|
raise ArgumentError unless entry.is_a?(Hash)
|
||||||
|
|
||||||
if entry['operator'] == 'axis_eq'
|
if entry['operator'] == 'axis_eq'
|
||||||
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
raise ArgumentError unless (entry.keys - ['field', 'operator', 'grain', 'value']).empty?
|
||||||
value = entry['value']
|
value = entry['value']
|
||||||
unless value.nil? || value.is_a?(String) || value.is_a?(Numeric) || [true, false].include?(value)
|
unless (value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
||||||
|
[true, false].include?(value))
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
raise ArgumentError if value.is_a?(String) && value.bytesize > 8192
|
||||||
|
|
||||||
|
if (!HistoryAccess.admin? && CountsAxes.private_field?(entry['field']) &&
|
||||||
|
(value == CountsAxes::MASKED_VALUE ||
|
||||||
|
(value == CountsAxes::MASKED_TEXT &&
|
||||||
|
(@definition.fields[entry['field']] != :string ||
|
||||||
|
['tags', 'changed_field'].include?(entry['field'])))))
|
||||||
|
field = entry.fetch('field')
|
||||||
|
raise ArgumentError unless @definition.axes(@population.history).include?(field)
|
||||||
|
if entry.key?('grain')
|
||||||
|
unless (@definition.fields[field] == :datetime &&
|
||||||
|
CountsAxes::GRAINS.include?(entry['grain']))
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return scope.none
|
||||||
|
end
|
||||||
|
scope = @population.condition_scope(scope, entry.fetch('field'))
|
||||||
return @population.axis_condition(scope, entry)
|
return @population.axis_condition(scope, entry)
|
||||||
end
|
end
|
||||||
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
allowed = ['field', 'operator', 'value', 'end', 'blank', 'input', 'values']
|
||||||
@@ -111,6 +129,13 @@ class CountsCondition
|
|||||||
|
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
type = field[:type]
|
type = field[:type]
|
||||||
|
# Preserve typed input compatibility; display markers are never real typed values.
|
||||||
|
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||||
|
entry['value'] == CountsAxes::MASKED_TEXT && type != :string && !entry['blank'])
|
||||||
|
return scope.none unless entry['operator'] == 'ne'
|
||||||
|
|
||||||
|
return @population.condition_scope(scope, field[:key])
|
||||||
|
end
|
||||||
blank = blank_sql(expression, type)
|
blank = blank_sql(expression, type)
|
||||||
if entry['blank']
|
if entry['blank']
|
||||||
sql = if source && field[:collection]
|
sql = if source && field[:collection]
|
||||||
@@ -119,6 +144,7 @@ class CountsCondition
|
|||||||
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
"NOT EXISTS (SELECT 1 FROM #{ source } WHERE NOT (#{ blank }))"
|
||||||
else blank
|
else blank
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
return scope.where(Arel.sql(sql))
|
return scope.where(Arel.sql(sql))
|
||||||
end
|
end
|
||||||
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
if ['in', 'not_in', 'any', 'all', 'none'].include?(entry['operator'])
|
||||||
@@ -131,6 +157,7 @@ class CountsCondition
|
|||||||
validate_enum!(field[:key], parsed.first) if type == :enum
|
validate_enum!(field[:key], parsed.first) if type == :enum
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if type == :interval
|
if type == :interval
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
return interval(scope, source, expression, parsed)
|
return interval(scope, source, expression, parsed)
|
||||||
end
|
end
|
||||||
clauses = []
|
clauses = []
|
||||||
@@ -158,6 +185,7 @@ class CountsCondition
|
|||||||
elsif operator == 'ne'
|
elsif operator == 'ne'
|
||||||
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
predicate = "(NOT (#{ predicate }) OR #{ blank })"
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
scope.where(predicate, *binds)
|
scope.where(predicate, *binds)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -168,6 +196,13 @@ class CountsCondition
|
|||||||
|
|
||||||
values = entry['values']
|
values = entry['values']
|
||||||
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
raise ArgumentError unless values.is_a?(Array) && values.length <= 100
|
||||||
|
if (!HistoryAccess.admin? && CountsAxes.private_field?(field[:key]) &&
|
||||||
|
values.include?(CountsAxes::MASKED_TEXT) &&
|
||||||
|
(field[:type] != :string || field[:set_type] == 'tag_reference'))
|
||||||
|
return scope.none if entry['operator'] == 'all'
|
||||||
|
|
||||||
|
values = values - [CountsAxes::MASKED_TEXT]
|
||||||
|
end
|
||||||
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
raise ArgumentError if [entry['value'], entry['end']].any? { !_1.nil? && _1 != '' }
|
||||||
|
|
||||||
parsed = values.map do |value|
|
parsed = values.map do |value|
|
||||||
@@ -191,7 +226,10 @@ class CountsCondition
|
|||||||
|
|
||||||
operator = entry['operator']
|
operator = entry['operator']
|
||||||
if parsed.empty?
|
if parsed.empty?
|
||||||
return ['in', 'any'].include?(operator) ? scope.none : scope
|
return scope.none if ['in', 'any'].include?(operator)
|
||||||
|
return scope if entry['values'].empty?
|
||||||
|
|
||||||
|
return @population.condition_scope(scope, field[:key])
|
||||||
end
|
end
|
||||||
source, expression = source_for(field[:key])
|
source, expression = source_for(field[:key])
|
||||||
if field[:set_type] == 'tag_reference'
|
if field[:set_type] == 'tag_reference'
|
||||||
@@ -212,6 +250,7 @@ class CountsCondition
|
|||||||
predicate = source ? "NOT #{ predicate }" :
|
predicate = source ? "NOT #{ predicate }" :
|
||||||
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
"(NOT #{ predicate } OR #{ blank_sql(expression, field[:type]) })"
|
||||||
end
|
end
|
||||||
|
scope = @population.condition_scope(scope, field[:key])
|
||||||
scope.where(predicate, *parsed)
|
scope.where(predicate, *parsed)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,13 @@ class CountsPopulation
|
|||||||
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
@authorisation_flg ? HistoryAccess.sql(model, row) : 'TRUE'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Each predicate keeps its own visibility restriction, including OR branches.
|
||||||
|
def condition_scope(scope, field)
|
||||||
|
return scope if HistoryAccess.admin? || !CountsAxes.private_field?(field)
|
||||||
|
|
||||||
|
scope.where(Arel.sql(CountsAxes.visibility_sql(definition, field)))
|
||||||
|
end
|
||||||
|
|
||||||
def axis_condition(scope, entry)
|
def axis_condition(scope, entry)
|
||||||
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
@@ -209,6 +216,7 @@ class CountsPopulation
|
|||||||
def apply_tags
|
def apply_tags
|
||||||
raise ArgumentError unless definition.resource == 'posts'
|
raise ArgumentError unless definition.resource == 'posts'
|
||||||
|
|
||||||
|
@scope = condition_scope(@scope, 'tags')
|
||||||
if params['tag'].present?
|
if params['tag'].present?
|
||||||
@scope = @scope.where(snapshot_membership(
|
@scope = @scope.where(snapshot_membership(
|
||||||
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
"#{ params.fetch('kind', 'internal') }:#{ params['tag'] }"))
|
||||||
|
|||||||
@@ -253,7 +253,7 @@ class CountsTableExport
|
|||||||
end
|
end
|
||||||
|
|
||||||
def summary_value(value, index, page)
|
def summary_value(value, index, page)
|
||||||
return '********' if value == CountsAxes::MASKED_VALUE
|
return CountsAxes::MASKED_TEXT if value == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
field = @query.axes.all.fetch(index).fetch('field')
|
field = @query.axes.all.fetch(index).fetch('field')
|
||||||
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
return @labels.fetch('bot', '') if field == 'user' && value.nil?
|
||||||
|
|||||||
@@ -220,19 +220,11 @@ class CountsTableQuery
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Public timeline predicates may include hidden records. Private predicates must
|
# Private predicates require visibility independently of their operator.
|
||||||
# never become a count oracle, even when the selected axes themselves are public.
|
# Ordering/measure state must not select a server-only masked coordinate.
|
||||||
def validate_summary_conditions!
|
def validate_summary_conditions!
|
||||||
return if mode != 'summary' || HistoryAccess.admin?
|
return if mode != 'summary' || HistoryAccess.admin?
|
||||||
|
|
||||||
fields = QueryState.records(@params, 'conditions') + QueryState.records(@params, 'drill')
|
|
||||||
private_fields = fields.any? { !CountsAxes::PUBLIC_FIELDS.include?(_1['field']) }
|
|
||||||
keys = ['tags', 'tag', 'kind', 'resource_id', 'user_id']
|
|
||||||
private_parameters = keys.any? do |key|
|
|
||||||
@params.any? { |name, value| (name == key || name.start_with?("#{ key }.")) && value != '' }
|
|
||||||
end
|
|
||||||
raise CountsAxes::PrivateCondition if private_fields || private_parameters
|
|
||||||
|
|
||||||
['column_order', 'measures'].each do |key|
|
['column_order', 'measures'].each do |key|
|
||||||
# Coordinates are compared only to already masked expressions, never raw
|
# Coordinates are compared only to already masked expressions, never raw
|
||||||
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
# values. Public coordinates remain sortable; a guessed private ID cannot match.
|
||||||
|
|||||||
+15
-2
@@ -9,8 +9,21 @@
|
|||||||
# end
|
# end
|
||||||
|
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
Language.find_or_create_by!(code: 'ain') { _1.name = 'ain' }
|
Language.find_or_create_by!(code: 'ja') { _1.name = '日本語' }
|
||||||
Script.find_or_create_by!(code: 'Kana') { _1.name = 'Kana' }
|
Script.find_or_create_by!(code: 'Jpan') { _1.name = '漢字、仮名文字およびラテン文字' }
|
||||||
|
Region.find_or_create_by!(code: 392) do
|
||||||
|
_1.alpha_code = 'JP'
|
||||||
|
_1.name = '日本国'
|
||||||
|
end
|
||||||
|
Locale.find_or_create_by!(code: 'ja') do
|
||||||
|
_1.language_code = 'ja'
|
||||||
|
_1.script_code = 'Jpan'
|
||||||
|
_1.region_code = 392
|
||||||
|
_1.name = '日本語'
|
||||||
|
end
|
||||||
|
|
||||||
|
Language.find_or_create_by!(code: 'ain') { _1.name = 'ヤウンモシルン アイヌ イタㇰ' }
|
||||||
|
Script.find_or_create_by!(code: 'Kana') { _1.name = 'カナモジ オヨビ ラテンモジ' }
|
||||||
end
|
end
|
||||||
|
|
||||||
post_url_sanitisation_rules = [
|
post_url_sanitisation_rules = [
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Counts masked search', type: :request do
|
||||||
|
let!(:tag) { create(:tag, primary_name: '秘密のタグ') }
|
||||||
|
let(:actor) { create(:user, :member) }
|
||||||
|
let(:admin) { create(:user, :admin) }
|
||||||
|
let(:base) do
|
||||||
|
{ 'resource' => 'tags', 'definition' => 'history', 'mode' => 'summary',
|
||||||
|
'locale' => 'ja', 'execution_at' => '2025-01-01T00:00:00Z',
|
||||||
|
'timezone' => 'UTC', 'rows' => '', 'cols' => '', 'sort' => '' }
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
[1, 2].each do |number|
|
||||||
|
TagVersion.create!(tag:, version_no: number,
|
||||||
|
event_type: number == 1 ? 'create' : 'discard', name: '秘密のタグ',
|
||||||
|
category: 'general', aliases: '秘密の別名', parent_tag_ids: '',
|
||||||
|
names: { ja: { name: '秘密のタグ', script_code: 'Jpan', auto_generated: false,
|
||||||
|
aliases: [{ name: '秘密の別名', script_code: 'Jpan' }] } },
|
||||||
|
created_by_user: actor, created_at: Time.utc(2023, number))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def query(extra = {})
|
||||||
|
get '/counts/query', params: base.merge(extra)
|
||||||
|
expect(response).to have_http_status(:ok), extra.inspect
|
||||||
|
response.parsed_body
|
||||||
|
end
|
||||||
|
|
||||||
|
def predicate(field, operator, value, index = 0)
|
||||||
|
prefix = "conditions.#{ index }"
|
||||||
|
result = { 'conditions' => '', "#{ prefix }.field" => field,
|
||||||
|
"#{ prefix }.operator" => operator }
|
||||||
|
if ['in', 'any', 'all', 'none', 'not_in'].include?(operator)
|
||||||
|
result.merge!("#{ prefix }.values" => '1', 'condition_values' => '',
|
||||||
|
"condition_values.#{ index }.condition" => index.to_s,
|
||||||
|
"condition_values.#{ index }.value_type" => 'string',
|
||||||
|
"condition_values.#{ index }.value" => value)
|
||||||
|
else
|
||||||
|
result["#{ prefix }.value"] = value
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects every name and alias predicate on private history' do
|
||||||
|
['name', 'name_ja', 'names.name', 'aliases.name', 'names.aliases.name'].each do |field|
|
||||||
|
secret = field.include?('aliases') ? '秘密の別名' : '秘密のタグ'
|
||||||
|
multiple = !['name', 'name_ja'].include?(field)
|
||||||
|
operators = multiple ? ['any', 'all', 'none'] : ['in', 'not_in']
|
||||||
|
['eq', 'ne', 'like', *operators].each do |operator|
|
||||||
|
[secret, '********', '%', ''].each do |value|
|
||||||
|
expected = (value == '' && ['eq', 'ne', 'like'].include?(operator)) ? 2 : 0
|
||||||
|
expect(query(predicate(field, operator, value))['count']).to eq(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
blank = predicate(field, 'eq', '').merge('conditions.0.blank' => '1')
|
||||||
|
expect(query(blank)['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignores missing and empty scalar values unless blank is checked' do
|
||||||
|
['name', 'name_ja', 'names.name', 'aliases.name'].each do |field|
|
||||||
|
['eq', 'ne', 'like'].each do |operator|
|
||||||
|
condition = predicate(field, operator, '')
|
||||||
|
[condition, condition.except('conditions.0.value')].each do |empty|
|
||||||
|
expect(query(empty)['count']).to eq(2)
|
||||||
|
expect(query(empty.merge('conditions.0.blank' => '0'))['count']).to eq(2)
|
||||||
|
expect(query(empty.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps details totals unchanged for inactive private predicates' do
|
||||||
|
settings = predicate('name', 'ne', '').merge('mode' => 'details')
|
||||||
|
result = query(settings)
|
||||||
|
expect(result['count']).to eq(0)
|
||||||
|
expect(result['versions']).to be_empty
|
||||||
|
expect(result['output_count']).to eq(2)
|
||||||
|
active = query(settings.merge('conditions.0.blank' => '1'))
|
||||||
|
expect(active['output_count']).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignores missing sets but preserves explicit empty set semantics' do
|
||||||
|
{ 'name' => ['in', 'not_in'], 'aliases.name' => ['any', 'all', 'none'] }.each do
|
||||||
|
|field, operators|
|
||||||
|
operators.each do |operator|
|
||||||
|
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||||
|
'conditions.0.operator' => operator }
|
||||||
|
expect(query(condition)['count']).to eq(2)
|
||||||
|
empty = condition.merge('conditions.0.values' => '1', 'condition_values' => '')
|
||||||
|
expected = ['in', 'any'].include?(operator) ? 0 : 2
|
||||||
|
expect(query(empty)['count']).to eq(expected)
|
||||||
|
expect(query(condition.merge('conditions.0.blank' => '1'))['count']).to eq(0)
|
||||||
|
# An explicit empty string element is a real value, not an unspecified set.
|
||||||
|
expect(query(predicate(field, operator, ''))['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignores empty private predicates regardless of their position' do
|
||||||
|
first = predicate('name', 'ne', '')
|
||||||
|
.merge(predicate('resource_id', 'eq', tag.id.to_s, 1))
|
||||||
|
last = predicate('resource_id', 'eq', tag.id.to_s)
|
||||||
|
.merge(predicate('aliases.name', 'like', '', 1))
|
||||||
|
expect(query(first)['count']).to eq(2)
|
||||||
|
expect(query(last)['count']).to eq(2)
|
||||||
|
active = predicate('name', 'ne', 'unrelated')
|
||||||
|
.merge(predicate('aliases.name', 'like', '', 1))
|
||||||
|
expect(query(active)['count']).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'searches public literal markers and treats axis predicates as explicit' do
|
||||||
|
public_tag = create(:tag, primary_name: '********')
|
||||||
|
TagVersion.create!(tag: public_tag, version_no: 1, event_type: 'create',
|
||||||
|
name: '********', category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
|
names: { ja: { name: '********', script_code: 'Jpan', auto_generated: false,
|
||||||
|
aliases: [] } }, created_at: Time.utc(2023))
|
||||||
|
expect(query['count']).to eq(3)
|
||||||
|
['eq', 'ne', 'like', 'in', 'not_in'].each do |operator|
|
||||||
|
expected = ['ne', 'not_in'].include?(operator) ? 0 : 1
|
||||||
|
expect(query(predicate('name', operator, '********'))['count']).to eq(expected)
|
||||||
|
end
|
||||||
|
blank = predicate('aliases.name', 'eq', '').merge('conditions.0.blank' => '1')
|
||||||
|
expect(query(blank)['count']).to eq(1)
|
||||||
|
['********', '', nil].each do |value|
|
||||||
|
condition = { 'conditions' => '', 'conditions.0.field' => 'name',
|
||||||
|
'conditions.0.operator' => 'axis_eq',
|
||||||
|
'conditions.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||||
|
condition['conditions.0.value'] = value unless value.nil?
|
||||||
|
expected = value == '********' ? 1 : 0
|
||||||
|
expect(query(condition)['count']).to eq(expected)
|
||||||
|
drill = { 'drill' => '', 'drill.0.field' => 'name',
|
||||||
|
'drill.0.value_type' => value.nil? ? 'null' : 'string' }
|
||||||
|
drill['drill.0.value'] = value unless value.nil?
|
||||||
|
expect(query(drill)['count']).to eq(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps public identity and timeline predicates searchable' do
|
||||||
|
expect(query['count']).to eq(2)
|
||||||
|
values = { 'resource_id' => tag.id.to_s, 'version_no' => '2',
|
||||||
|
'event_type' => 'discard', 'user' => actor.id.to_s,
|
||||||
|
'created_at' => '2023-02-01T00:00:00Z' }
|
||||||
|
values.each do |field, value|
|
||||||
|
expected = ['resource_id', 'user'].include?(field) ? 2 : 1
|
||||||
|
expect(query(predicate(field, 'eq', value))['count']).to eq(expected)
|
||||||
|
end
|
||||||
|
condition = predicate('name', 'ne', '秘密のタグ')
|
||||||
|
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||||
|
expect(query(condition)['count']).to eq(0)
|
||||||
|
expect(query(predicate('category', 'eq', '********'))['count']).to eq(0)
|
||||||
|
expect(query(predicate('category', 'eq', 'general'))['count']).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps public predicates available after combining multiple conditions' do
|
||||||
|
condition = predicate('resource_id', 'eq', tag.id.to_s)
|
||||||
|
.merge(predicate('event_type', 'eq', 'discard', 1))
|
||||||
|
expect(query(condition)['count']).to eq(1)
|
||||||
|
condition.merge!(predicate('name', 'ne', 'unrelated', 2))
|
||||||
|
expect(query(condition)['count']).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps real name searches for administrators and excludes private details' do
|
||||||
|
condition = predicate('name', 'eq', '秘密のタグ')
|
||||||
|
details = query(condition.merge('mode' => 'details'))
|
||||||
|
expect(details['versions']).to be_empty
|
||||||
|
expect(details['output_count']).to eq(0)
|
||||||
|
masked = query(predicate('name', 'eq', '********').merge('mode' => 'details'))
|
||||||
|
expect(masked['versions']).to be_empty
|
||||||
|
expect(masked['output_count']).to eq(0)
|
||||||
|
sign_in_as(admin)
|
||||||
|
expect(query(condition)['count']).to eq(2)
|
||||||
|
expect(query(condition.merge('mode' => 'details'))['versions'].length).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects private names reached through a post tag snapshot' do
|
||||||
|
post = create(:post)
|
||||||
|
PostVersion.create!(post:, version_no: 1, event_type: 'create', title: 'post',
|
||||||
|
url: post.url, tags: '', parent_post_ids: '',
|
||||||
|
tags_json: [{ tag_id: tag.id, version_no: 1, name: '秘密のタグ',
|
||||||
|
category: 'general', sections: [] }],
|
||||||
|
created_at: Time.utc(2024))
|
||||||
|
['tags.name', 'tags.names.name', 'tags.name_ja'].each do |field|
|
||||||
|
['eq', 'ne', 'like', 'any', 'all', 'none'].each do |operator|
|
||||||
|
['秘密のタグ', '********', '%'].each do |value|
|
||||||
|
settings = predicate(field, operator, value).merge('resource' => 'posts')
|
||||||
|
expect(query(settings)['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
blank = predicate(field, 'eq', '').merge('resource' => 'posts',
|
||||||
|
'conditions.0.blank' => '1')
|
||||||
|
expect(query(blank)['count']).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -354,15 +354,25 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||||
expect(response.body).not_to include('hidden title')
|
expect(response.body).not_to include('hidden title')
|
||||||
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||||
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
expect(masked['cells'].map { _1['values'] }).to eq([[record.id]])
|
||||||
expect(masked['cells'].first['count']).to eq(4)
|
expect(masked['cells'].first['count']).to eq(4)
|
||||||
expect(query['versions']).to be_empty
|
expect(query['versions']).to be_empty
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects private summary predicates instead of exposing a count oracle' do
|
it 'allows public identity predicates without exposing private snapshot fields' do
|
||||||
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
PostVersion.create!(post: record, version_no: 4, event_type: 'discard',
|
||||||
expect(response).to have_http_status(:forbidden)
|
url: record.url, title: 'hidden title', tags: '', tags_json: [],
|
||||||
expect(json).to eq('error' => 'private_condition')
|
parent_post_ids: '', created_at: Time.utc(2024, 3))
|
||||||
|
|
||||||
|
get '/counts/query', params: base.merge(mode: 'summary',
|
||||||
|
resource_id: record.id.to_s, rows: 'resource_id', cols: 'title')
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(json['count']).to eq(4)
|
||||||
|
expect(json['cells'].sum { _1['count'] }).to eq(4)
|
||||||
|
expect(json['cells'].map { _1['values'].last })
|
||||||
|
.to include(CountsAxes::MASKED_VALUE)
|
||||||
|
expect(response.body).not_to include('hidden title')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'reports the combined axis limit without replacing it with a generic 400' do
|
it 'reports the combined axis limit without replacing it with a generic 400' do
|
||||||
|
|||||||
@@ -69,27 +69,29 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'preserves five tag occurrences after merging private tags and across pages' do
|
it 'counts one opaque collection per private version across pages' do
|
||||||
[nil, guest, member, admin].each do |user|
|
[nil, guest, member, admin].each do |user|
|
||||||
page = result(user)
|
page = result(user)
|
||||||
expect(grand(page)).to eq(5)
|
expected = user == admin ? 5 : 6
|
||||||
expect(page[:cells].sum { _1[:count] }).to eq(5)
|
expect(grand(page)).to eq(expected)
|
||||||
|
expect(page[:cells].sum { _1[:count] }).to eq(expected)
|
||||||
expect(page).not_to have_key(:excluded_count)
|
expect(page).not_to have_key(:excluded_count)
|
||||||
paged = result(user, 'limit' => '1')
|
paged = result(user, 'limit' => '1')
|
||||||
expect(grand(paged)).to eq(5)
|
expect(grand(paged)).to eq(expected)
|
||||||
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
expect(result(user, 'limit' => '1', 'page' => '2')[:group_count])
|
||||||
.to eq(page[:group_count])
|
.to eq(page[:group_count])
|
||||||
end
|
end
|
||||||
public_page = result(nil)
|
public_page = result(nil)
|
||||||
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
hidden = public_page[:cells].select { _1[:values].first == CountsAxes::MASKED_VALUE }
|
||||||
expect(hidden.sum { _1[:count] }).to eq(3)
|
expect(hidden.sum { _1[:count] }).to eq(4)
|
||||||
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
expect(public_page[:axis_labels]['a0']).not_to have_key("internal:#{ tags.first.id }")
|
||||||
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
expect(public_page.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'retains distinct membership when tags are not a row axis' do
|
it 'retains distinct membership when tags are not a row axis' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags'))).to eq(3)
|
expect(grand(result(user, 'rows' => 'event_type', 'cols' => 'tags')))
|
||||||
|
.to eq(user == admin ? 3 : 5)
|
||||||
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
expect(result(user, 'rows' => '', 'cols' => '')[:cells].sole[:count]).to eq(5)
|
||||||
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
details = result(user, 'mode' => 'details', 'rows' => '', 'cols' => '')
|
||||||
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
expect(details[:count]).to eq(user == admin ? 5 : 1)
|
||||||
@@ -121,14 +123,13 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
it 'counts distinct changed records when tags are not additive row tuples' do
|
it 'counts distinct changed records when tags are not additive row tuples' do
|
||||||
[nil, admin].each do |user|
|
[nil, admin].each do |user|
|
||||||
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
page = result(user, 'rows' => 'changed_field', 'cols' => 'event_type')
|
||||||
expect(grand(page)).to eq(2)
|
expect(grand(page)).to eq(user == admin ? 2 : 4)
|
||||||
if user.nil?
|
if user.nil?
|
||||||
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
expect(page[:cells].map { _1[:values].first }.uniq).to eq([CountsAxes::MASKED_VALUE])
|
||||||
end
|
end
|
||||||
# Changed discard snapshots have no tags: no full cell contributes here.
|
# Private empty collections contribute the same opaque tuple as populated ones.
|
||||||
empty = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
combined = result(user, 'rows' => 'changed_field', 'cols' => 'tags')
|
||||||
expect(empty[:cells]).to be_empty
|
expect(grand(combined)).to eq(user == admin ? 0 : 4)
|
||||||
expect(grand(empty)).to eq(0)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
expect(page[:row_headers]).to include(hash_including(values: [nil], kind: 'data'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sorts public numeric values numerically alongside a masked group' do
|
it 'sorts public version numbers numerically including formerly hidden records' do
|
||||||
post = PostVersion.order(:id).last.post
|
post = PostVersion.order(:id).last.post
|
||||||
[2, 10].each do |number|
|
[2, 10].each do |number|
|
||||||
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
PostVersion.create!(post:, version_no: number, event_type: 'update',
|
||||||
@@ -151,27 +152,196 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
end
|
end
|
||||||
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
page = result(nil, 'rows' => 'version_no', 'cols' => '')
|
||||||
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
values = page[:row_headers].select { _1[:kind] == 'data' }.map { _1[:values].first }
|
||||||
expect(values.reject { _1 == CountsAxes::MASKED_VALUE }.map(&:to_i)).to eq([1, 2, 10])
|
expect(values.map(&:to_i)).to eq([1, 2, 10])
|
||||||
expect(values.last).to eq(CountsAxes::MASKED_VALUE)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'rejects private predicates and supplied measure coordinates before SQL labels escape' do
|
it 'keeps public identity predicates and excludes private attributes' do
|
||||||
probes = [{ 'resource_id' => PostVersion.first.post_id.to_s },
|
hidden_id = PostVersion.first.post_id.to_s
|
||||||
{ 'tags' => 'A' },
|
base = { 'rows' => '', 'cols' => '' }
|
||||||
{ 'conditions' => '', 'conditions.0.field' => 'title', 'conditions.0.operator' => 'eq',
|
condition = lambda do |field, operator, value|
|
||||||
'conditions.0.value' => 'hidden-title-0' },
|
{ 'conditions' => '', 'conditions.0.field' => field,
|
||||||
{ 'drill' => '', 'drill.0.field' => 'tags', 'drill.0.value_type' => 'string',
|
'conditions.0.operator' => operator, 'conditions.0.value' => value }
|
||||||
'drill.0.value' => "internal:#{ tags.first.id }" },
|
end
|
||||||
|
|
||||||
|
expect(result(nil, base.merge('resource_id' => hidden_id))[:cells].sole[:count]).to eq(2)
|
||||||
|
expect(result(nil, base.merge(condition.call('version_no', 'eq', '2')))[:cells]
|
||||||
|
.sole[:count]).to eq(2)
|
||||||
|
expect(result(nil, base.merge(condition.call('user', 'eq', member.id.to_s)))[:cells]
|
||||||
|
.sole[:count]).to eq(5)
|
||||||
|
expect(result(nil, base.merge(condition.call('title', 'eq', 'hidden-title-0')))[:cells]
|
||||||
|
.sole[:count]).to eq(0)
|
||||||
|
expect(result(nil, base.merge(condition.call('title', 'ne', 'hidden-title-0')))[:cells]
|
||||||
|
.sole[:count]).to eq(1)
|
||||||
|
expect(result(nil, base.merge(condition.call('title', 'eq', '********')))[:cells]
|
||||||
|
.sole[:count]).to eq(0)
|
||||||
|
expect(result(nil, base.merge(condition.call('video_ms', 'eq', '********')))[:cells]
|
||||||
|
.sole[:count]).to eq(0)
|
||||||
|
expect(result(nil, base.merge(condition.call('title', 'like', '%title%')))[:cells]
|
||||||
|
.sole[:count]).to eq(1)
|
||||||
|
expect(result(nil, base.merge(condition.call('title', 'like', '%')))[:cells]
|
||||||
|
.sole[:count]).to eq(1)
|
||||||
|
expect(result(nil, base.merge('tags' => 'C'))[:cells].sole[:count]).to eq(1)
|
||||||
|
expect(result(nil, base.merge('tags' => 'not:C'))[:cells].sole[:count]).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'retains public OR branches while rejecting private predicates on hidden rows' do
|
||||||
|
HistoryAccess.set(user: nil) do
|
||||||
|
population = CountsPopulation.new(params.merge('rows' => '', 'cols' => ''),
|
||||||
|
authorisation_flg: false)
|
||||||
|
condition = CountsCondition.new(population)
|
||||||
|
public_scope = condition.apply(population.scope,
|
||||||
|
{ 'field' => 'event_type', 'operator' => 'eq', 'value' => 'discard' })
|
||||||
|
private_scope = condition.apply(population.scope,
|
||||||
|
{ 'field' => 'title', 'operator' => 'ne', 'value' => 'unrelated' })
|
||||||
|
masked_scope = condition.apply(population.scope,
|
||||||
|
{ 'field' => 'title', 'operator' => 'eq', 'value' => '********' })
|
||||||
|
expect(public_scope.except(:select).count).to eq(2)
|
||||||
|
expect(public_scope.or(private_scope).except(:select).count).to eq(3)
|
||||||
|
expect(private_scope.or(public_scope).except(:select).count).to eq(3)
|
||||||
|
expect(public_scope.or(masked_scope).except(:select).count).to eq(2)
|
||||||
|
expect(public_scope.merge(private_scope).except(:select).count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes private collections from positive, negative, AND and OR tag searches' do
|
||||||
|
settings = { 'rows' => '', 'cols' => '' }
|
||||||
|
{ 'C' => 1, 'not:C' => 0, 'not:unknown' => 1, '********' => 0,
|
||||||
|
'not:********' => 1, 'not:C C' => 0, 'not:C type:or C' => 1 }.each do |value, count|
|
||||||
|
expect(result(nil, settings.merge('tags' => value))[:cells].sole[:count]).to eq(count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'ignores empty numeric and interval bounds without restricting private history' do
|
||||||
|
settings = { 'rows' => '', 'cols' => '', 'conditions' => '' }
|
||||||
|
{ 'video_ms' => ['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'],
|
||||||
|
'tags.sections' => ['range'] }.each do |field, operators|
|
||||||
|
operators.each do |operator|
|
||||||
|
condition = settings.merge('conditions.0.field' => field,
|
||||||
|
'conditions.0.operator' => operator)
|
||||||
|
empty = condition.merge('conditions.0.value' => '')
|
||||||
|
expect(result(nil, condition)[:cells].sole[:count]).to eq(5)
|
||||||
|
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||||
|
expect(result(nil, empty.merge('conditions.0.blank' => '0'))[:cells]
|
||||||
|
.sole[:count]).to eq(5)
|
||||||
|
if operator == 'range'
|
||||||
|
empty['conditions.0.end'] = ''
|
||||||
|
expect(result(nil, empty)[:cells].sole[:count]).to eq(5)
|
||||||
|
['conditions.0.value', 'conditions.0.end'].each do |key|
|
||||||
|
active = empty.merge(key => '100')
|
||||||
|
expect(result(nil, active)[:cells].sole[:count]).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps typed marker sets active after incompatible marker values are removed' do
|
||||||
|
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||||
|
'conditions.0.field' => 'video_ms', 'conditions.0.values' => '1',
|
||||||
|
'condition_values' => '', 'condition_values.0.condition' => '0',
|
||||||
|
'condition_values.0.value_type' => 'string',
|
||||||
|
'condition_values.0.value' => '********' }
|
||||||
|
['in', 'not_in'].each do |operator|
|
||||||
|
expected = operator == 'in' ? 0 : 1
|
||||||
|
condition = settings.merge('conditions.0.operator' => operator)
|
||||||
|
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'restricts active numeric comparisons and preserves empty selection semantics' do
|
||||||
|
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||||
|
'conditions.0.field' => 'video_ms' }
|
||||||
|
['eq', 'ne', 'gt', 'ge', 'lt', 'le', 'range'].each do |operator|
|
||||||
|
condition = settings.merge('conditions.0.operator' => operator,
|
||||||
|
'conditions.0.value' => '0')
|
||||||
|
condition['conditions.0.end'] = '100' if operator == 'range'
|
||||||
|
# The one public row is NULL; private NULLs and real values never match.
|
||||||
|
expected = operator == 'ne' ? 1 : 0
|
||||||
|
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||||
|
end
|
||||||
|
['in', 'not_in'].each do |operator|
|
||||||
|
condition = settings.merge('conditions.0.operator' => operator,
|
||||||
|
'conditions.0.values' => '1', 'condition_values' => '')
|
||||||
|
expected = operator == 'not_in' ? 5 : 0
|
||||||
|
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||||
|
end
|
||||||
|
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||||
|
expect(result(nil, blank)[:cells].sole[:count]).to eq(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'preserves empty tag sets and restricts active blank collection predicates' do
|
||||||
|
settings = { 'rows' => '', 'cols' => '', 'conditions' => '',
|
||||||
|
'conditions.0.field' => 'tags' }
|
||||||
|
['any', 'all', 'none'].each do |operator|
|
||||||
|
condition = settings.merge('conditions.0.operator' => operator,
|
||||||
|
'conditions.0.values' => '1', 'condition_values' => '')
|
||||||
|
expected = operator == 'any' ? 0 : 5
|
||||||
|
expect(result(nil, condition)[:cells].sole[:count]).to eq(expected)
|
||||||
|
end
|
||||||
|
blank = settings.merge('conditions.0.operator' => 'eq', 'conditions.0.blank' => '1')
|
||||||
|
expect(result(nil, blank)[:cells].sole[:count]).to eq(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects server-only coordinates in column order' do
|
||||||
|
probes = [
|
||||||
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
{ 'cols' => 'tags', 'column_order' => '', 'column_order.0.value_type' => 'string',
|
||||||
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
'column_order.0.value' => CountsAxes::MASKED_VALUE }]
|
||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
probes.each do |probe|
|
probes.each do |probe|
|
||||||
expect { CountsTableQuery.new(params.merge(probe)) }
|
expect { CountsTableQuery.new(params.merge(probe)).call }
|
||||||
.to raise_error(CountsAxes::PrivateCondition)
|
.to raise_error(CountsAxes::PrivateCondition)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
it 'keeps private collection and change counts independent of stored elements' do
|
||||||
|
settings = [
|
||||||
|
{ 'rows' => 'tags', 'cols' => 'changed_field' },
|
||||||
|
{ 'rows' => 'changed_field tags', 'cols' => '' }]
|
||||||
|
original = settings.map { result(nil, _1) }
|
||||||
|
snapshot = tags.map do |tag|
|
||||||
|
{ tag_id: tag.id, version_no: 1, sections: [] }
|
||||||
|
end
|
||||||
|
hidden_posts = PostVersion.where(event_type: 'discard').pluck(:post_id)
|
||||||
|
PostVersion.where(post_id: hidden_posts).update_all(
|
||||||
|
title: 'same hidden value', tags_json: snapshot)
|
||||||
|
expect(settings.map { result(nil, _1) }).to eq(original)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'excludes private tag and change coordinates from drill conditions' do
|
||||||
|
['tags', 'changed_field'].each do |field|
|
||||||
|
settings = { 'rows' => field, 'cols' => '' }
|
||||||
|
page = result(nil, settings)
|
||||||
|
cell = page[:cells].find { _1[:values] == [CountsAxes::MASKED_VALUE] }
|
||||||
|
expect(cell[:count]).to eq(4)
|
||||||
|
[CountsAxes::MASKED_VALUE, CountsAxes::MASKED_TEXT].each do |value|
|
||||||
|
drill = { 'drill' => '', 'drill.0.field' => field,
|
||||||
|
'drill.0.value_type' => 'string', 'drill.0.value' => value }
|
||||||
|
expect(result(nil, settings.merge(drill))[:cells]).to be_empty
|
||||||
|
details = result(nil, settings.merge(drill).merge('mode' => 'details'))
|
||||||
|
expect(details[:versions]).to be_empty
|
||||||
|
condition = { 'conditions' => '', 'conditions.0.field' => field,
|
||||||
|
'conditions.0.operator' => 'axis_eq',
|
||||||
|
'conditions.0.value_type' => 'string', 'conditions.0.value' => value }
|
||||||
|
expect(result(nil, settings.merge(condition))[:cells]).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects real private tag and change coordinates for non-admin users' do
|
||||||
|
[nil, guest, member].each do |user|
|
||||||
|
{ 'tags' => "internal:#{ tags.first.id }", 'changed_field' => 'title' }.each do
|
||||||
|
|field, value|
|
||||||
|
settings = { 'rows' => '', 'cols' => '', 'drill' => '',
|
||||||
|
'drill.0.field' => field, 'drill.0.value_type' => 'string',
|
||||||
|
'drill.0.value' => value }
|
||||||
|
expect(result(user, settings)[:cells].sole[:count]).to eq(0)
|
||||||
|
expect(result(admin, settings)[:cells].sole[:count]).to be > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
it 'keeps public column sorting usable while guessed private coordinates match nothing' do
|
||||||
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
first = result(nil, 'rows' => 'event_type', 'cols' => 'tags')
|
||||||
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
probe = result(nil, 'rows' => 'event_type', 'cols' => 'tags',
|
||||||
@@ -187,7 +357,7 @@ RSpec.describe 'Counts masked pivot contracts' do
|
|||||||
HistoryAccess.set(user: nil) do
|
HistoryAccess.set(user: nil) do
|
||||||
payload = params.merge('format' => 'json')
|
payload = params.merge('format' => 'json')
|
||||||
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
json = JSON.parse(CountsTableExport.new(payload).body.to_a.join)
|
||||||
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(5)
|
expect(json['data'].sum { _1['kind'] == 'data' ? _1['count'] : 0 }).to eq(6)
|
||||||
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
expect(json.to_json).not_to include('hidden-title', 'private-discard')
|
||||||
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
csv = CountsTableExport.new(payload.merge('format' => 'csv')).body.to_a.join
|
||||||
expect(csv).to include('********')
|
expect(csv).to include('********')
|
||||||
|
|||||||
@@ -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 only by the Unicode Mongolian sample in the development layout route.
|
|
||||||
The font and license are served together from this directory.
|
|
||||||
+11
-16
@@ -20,13 +20,13 @@ import { Toaster } from '@/components/ui/toaster'
|
|||||||
import { isApiError } from '@/lib/api'
|
import { isApiError } from '@/lib/api'
|
||||||
import { initialiseUserSession } from '@/lib/users'
|
import { initialiseUserSession } from '@/lib/users'
|
||||||
import { prepareHelpPage } from '@/lib/help'
|
import { prepareHelpPage } from '@/lib/help'
|
||||||
import { fetchLocales, isLocaleCode, isVerticalPreviewPath, legacyLocalePath,
|
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||||
localiseTo, switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||||
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { isCountsPath } from '@/lib/routeLocales'
|
import { isCountsPath } from '@/lib/routeLocales'
|
||||||
import { switchPostFilterLocale } from '@/lib/posts'
|
import { switchPostFilterLocale } from '@/lib/posts'
|
||||||
import { switchWikiLocale } from '@/lib/wiki'
|
import { switchWikiLocale } from '@/lib/wiki'
|
||||||
import { useLocale, useVerticalPreview } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { applyClientAnimationMode,
|
import { applyClientAnimationMode,
|
||||||
applyClientAppearance,
|
applyClientAppearance,
|
||||||
fetchUserThemeSlots,
|
fetchUserThemeSlots,
|
||||||
@@ -267,7 +267,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
const client = useQueryClient ()
|
const client = useQueryClient ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const verticalPreview = useVerticalPreview ()
|
|
||||||
const route = resolvePageRoute (location.pathname)
|
const route = resolvePageRoute (location.pathname)
|
||||||
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
|
const robots = isCountsPath (location.pathname) ? 'noindex' : route?.route.handle?.robots
|
||||||
const pagePath = unlocalisedPath (location.pathname)
|
const pagePath = unlocalisedPath (location.pathname)
|
||||||
@@ -394,8 +393,7 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
|
layout={(animationMode === 'off' || countsPage) ? false : 'position'}
|
||||||
transition={{ layout: appLayoutTransition }}
|
transition={{ layout: appLayoutTransition }}
|
||||||
data-vertical-preview={verticalPreview || undefined}
|
className="relative flex h-dvh w-full flex-col overflow-y-hidden">
|
||||||
className="app-layout relative flex h-dvh w-full flex-col overflow-y-hidden">
|
|
||||||
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
<TopNav user={user} locales={locales} resolveLocaleSwitch={switchLocale}/>
|
||||||
<RouteTransitionWrapper
|
<RouteTransitionWrapper
|
||||||
user={user}
|
user={user}
|
||||||
@@ -414,7 +412,6 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
|
|||||||
const LocaleRoute: FC = () => {
|
const LocaleRoute: FC = () => {
|
||||||
const { locale } = useParams ()
|
const { locale } = useParams ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const verticalPreview = isVerticalPreviewPath (location.pathname)
|
|
||||||
const { i18n } = useTranslation ()
|
const { i18n } = useTranslation ()
|
||||||
const { data, isError } = useQuery ({
|
const { data, isError } = useQuery ({
|
||||||
queryKey: localesKeys.index,
|
queryKey: localesKeys.index,
|
||||||
@@ -424,11 +421,10 @@ const LocaleRoute: FC = () => {
|
|||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnReconnect: true })
|
refetchOnReconnect: true })
|
||||||
const contentLocale = verticalPreview ? 'ja' : locale
|
|
||||||
const activeLocale =
|
const activeLocale =
|
||||||
(isLocaleCode (contentLocale)
|
(isLocaleCode (locale)
|
||||||
&& data?.locales.some (entry => entry.code === contentLocale))
|
&& data?.locales.some (entry => entry.code === locale))
|
||||||
? contentLocale
|
? locale
|
||||||
: null
|
: null
|
||||||
|
|
||||||
useLayoutEffect (() => {
|
useLayoutEffect (() => {
|
||||||
@@ -439,15 +435,15 @@ const LocaleRoute: FC = () => {
|
|||||||
}, [activeLocale, i18n])
|
}, [activeLocale, i18n])
|
||||||
|
|
||||||
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
|
const redirect = legacyLocalePath (location, pathname => resolvePageRoute (pathname) != null)
|
||||||
if (!(isLocaleCode (locale)) && !verticalPreview && redirect == null)
|
if (!(isLocaleCode (locale)) && redirect == null)
|
||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
if (isError && data == null)
|
if (isError && data == null)
|
||||||
return <ServiceUnavailable/>
|
return <ServiceUnavailable/>
|
||||||
if (data == null)
|
if (data == null)
|
||||||
return 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}/>
|
? <RoutedApp locales={data.locales}/>
|
||||||
: <NotFound/>
|
: <NotFound/>
|
||||||
}
|
}
|
||||||
@@ -459,8 +455,7 @@ const LocaleRoute: FC = () => {
|
|||||||
const LocaleRedirect = ({ to }: { to: string }) => {
|
const LocaleRedirect = ({ to }: { to: string }) => {
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
return <Navigate to={localiseTo (
|
return <Navigate to={switchLocalePath ({ ...location, pathname: to }, locale)} replace/>
|
||||||
{ ...location, pathname: to }, locale, location.pathname)} replace/>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const PostList: FC<Props> = ({ posts, onClick }) => {
|
|||||||
const cardRef = useRef<HTMLDivElement> (null)
|
const cardRef = useRef<HTMLDivElement> (null)
|
||||||
|
|
||||||
return (
|
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) => {
|
{posts.map ((post, i) => {
|
||||||
const sharedId = `page-${ post.id }`
|
const sharedId = `page-${ post.id }`
|
||||||
const layoutId = sharedId
|
const layoutId = sharedId
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { forwardRef, useMemo } from 'react'
|
import { forwardRef, useMemo } from 'react'
|
||||||
import { flushSync } from 'react-dom'
|
import { flushSync } from 'react-dom'
|
||||||
import { createPath, useLocation } from 'react-router-dom'
|
import { createPath } from 'react-router-dom'
|
||||||
|
|
||||||
import { isVerticalPreviewPath, localeFromPath,
|
import { localiseTo } from '@/lib/locales'
|
||||||
localiseTo, unlocalisedPath } from '@/lib/locales'
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
import { useOverlayStore } from '@/components/RouteBlockerOverlay'
|
||||||
import { prefetchForURL } from '@/lib/prefetchers'
|
import { prefetchForURL } from '@/lib/prefetchers'
|
||||||
@@ -39,18 +38,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
|
|||||||
|
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { pathname } = useLocation ()
|
|
||||||
const qc = useQueryClient ()
|
const qc = useQueryClient ()
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
|
||||||
const path = useMemo (
|
const path = useMemo (
|
||||||
() => {
|
() => {
|
||||||
const localised = localiseTo (to, locale, pathname)
|
const localised = localiseTo (to, locale)
|
||||||
const destination = typeof localised === 'string' ? localised : createPath (localised)
|
return typeof localised === 'string' ? localised : createPath (localised)
|
||||||
return (isVerticalPreviewPath (pathname) && localeFromPath (destination) === locale)
|
|
||||||
? `/mn-Mong${ unlocalisedPath (destination) }` : destination
|
|
||||||
},
|
},
|
||||||
[to, locale, pathname],
|
[to, locale],
|
||||||
)
|
)
|
||||||
const url = useMemo (() => {
|
const url = useMemo (() => {
|
||||||
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
return (new URL (path, currentRenderContext ()?.origin ?? 'http://localhost')).toString ()
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
import TagSearch from '@/components/TagSearch'
|
|
||||||
import * as api from '@/lib/api'
|
|
||||||
import { buildTag } from '@/test/factories'
|
|
||||||
import { renderWithProviders } from '@/test/render'
|
|
||||||
|
|
||||||
|
|
||||||
const LocationOutput = () => {
|
|
||||||
const location = useLocation ()
|
|
||||||
return <output data-testid="location">{location.pathname}{location.search}</output>
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const renderSearch = () => renderWithProviders (
|
|
||||||
<>
|
|
||||||
<TagSearch/>
|
|
||||||
<LocationOutput/>
|
|
||||||
</>, { route: '/ja/posts?page=3' })
|
|
||||||
|
|
||||||
|
|
||||||
describe ('TagSearch', () => {
|
|
||||||
beforeEach (() => {
|
|
||||||
vi.spyOn (api, 'apiGet').mockResolvedValue ([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('searches through native submission without an Enter key event', () => {
|
|
||||||
renderSearch ()
|
|
||||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
|
||||||
fireEvent.change (input, { target: { value: '虹夏 not:TEST type:or' } })
|
|
||||||
|
|
||||||
expect (input.form).toBeInstanceOf (HTMLFormElement)
|
|
||||||
fireEvent.submit (input.form!)
|
|
||||||
|
|
||||||
const query = new URLSearchParams ({ tags: '虹夏 not:test', match: 'any' })
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
|
||||||
`/ja/posts?${ query }`)
|
|
||||||
expect (input).toHaveValue ('虹夏 not:test type:or')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('requests a search action from the software keyboard', () => {
|
|
||||||
renderSearch ()
|
|
||||||
|
|
||||||
expect (screen.getByRole ('textbox')).toHaveAttribute ('enterkeyhint', 'search')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('searches through the form when Enter is pressed', async () => {
|
|
||||||
const user = userEvent.setup ()
|
|
||||||
renderSearch ()
|
|
||||||
|
|
||||||
await user.type (screen.getByRole ('textbox'), '虹夏{Enter}')
|
|
||||||
|
|
||||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
|
||||||
`/ja/posts?${ query }`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.each ([
|
|
||||||
{ label: 'composition flag', composing: true, keyCode: 13 },
|
|
||||||
{ label: 'legacy IME code', composing: false, keyCode: 229 }]) (
|
|
||||||
'cancels confirmation Enter with $label', ({ composing, keyCode }) => {
|
|
||||||
renderSearch ()
|
|
||||||
const input = screen.getByRole ('textbox')
|
|
||||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
|
||||||
const uncancelled = fireEvent.keyDown (input, {
|
|
||||||
key: 'Enter', isComposing: composing, keyCode })
|
|
||||||
|
|
||||||
expect (uncancelled).toBe (false)
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
|
||||||
expect (input).toHaveValue ('虹夏')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('cancels Enter throughout composition even without keyboard IME flags', () => {
|
|
||||||
renderSearch ()
|
|
||||||
const input = screen.getByRole ('textbox')
|
|
||||||
fireEvent.compositionStart (input)
|
|
||||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
|
||||||
const uncancelled = fireEvent.keyDown (input, {
|
|
||||||
key: 'Enter', isComposing: false, keyCode: 13 })
|
|
||||||
|
|
||||||
expect (uncancelled).toBe (false)
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
|
||||||
expect (input).toHaveValue ('虹夏')
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('blocks direct submission during composition and allows it after confirmation', () => {
|
|
||||||
renderSearch ()
|
|
||||||
const input = screen.getByRole<HTMLInputElement> ('textbox')
|
|
||||||
fireEvent.compositionStart (input)
|
|
||||||
fireEvent.change (input, { target: { value: '虹夏' } })
|
|
||||||
fireEvent.submit (input.form!)
|
|
||||||
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
|
||||||
expect (input).toHaveValue ('虹夏')
|
|
||||||
|
|
||||||
fireEvent.compositionEnd (input)
|
|
||||||
fireEvent.submit (input.form!)
|
|
||||||
|
|
||||||
const query = new URLSearchParams ({ tags: '虹夏', match: 'all' })
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent (
|
|
||||||
`/ja/posts?${ query }`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it ('selects the active suggestion without submitting the form', async () => {
|
|
||||||
vi.mocked (api.apiGet).mockResolvedValue ([buildTag ({ name: '虹夏' })])
|
|
||||||
const user = userEvent.setup ()
|
|
||||||
renderSearch ()
|
|
||||||
|
|
||||||
await user.type (screen.getByRole ('textbox'), '虹')
|
|
||||||
await screen.findByText ('虹夏')
|
|
||||||
await user.keyboard ('{ArrowDown}{Enter}')
|
|
||||||
|
|
||||||
expect (screen.getByRole ('textbox')).toHaveValue ('虹夏 ')
|
|
||||||
expect (screen.getByTestId ('location')).toHaveTextContent ('/ja/posts?page=3')
|
|
||||||
expect (screen.queryByText ('虹夏')).not.toBeInTheDocument ()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
// TODO: タグ入力系すべてに同様の処理あるため共通化する.
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
@@ -10,7 +10,7 @@ import { inputClass } from '@/lib/utils'
|
|||||||
|
|
||||||
import TagSearchBox from './TagSearchBox'
|
import TagSearchBox from './TagSearchBox'
|
||||||
|
|
||||||
import type { ChangeEvent, FC, FormEvent, KeyboardEvent } from 'react'
|
import type { ChangeEvent, FC, KeyboardEvent } from 'react'
|
||||||
|
|
||||||
import type { Tag } from '@/types'
|
import type { Tag } from '@/types'
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ const TagSearch: FC = () => {
|
|||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const compositionFlgRef = useRef (false)
|
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState (-1)
|
const [activeIndex, setActiveIndex] = useState (-1)
|
||||||
const [search, setSearch] = useState ('')
|
const [search, setSearch] = useState ('')
|
||||||
@@ -75,34 +74,7 @@ const TagSearch: FC = () => {
|
|||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
}, [locale])
|
}, [locale])
|
||||||
|
|
||||||
const handleSubmit = (ev: FormEvent<HTMLFormElement>) => {
|
|
||||||
ev.preventDefault ()
|
|
||||||
if (compositionFlgRef.current)
|
|
||||||
return
|
|
||||||
|
|
||||||
const parts = search.split (' ')
|
|
||||||
const match =
|
|
||||||
parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
|
||||||
navigate (`/posts?${
|
|
||||||
(new URLSearchParams ({
|
|
||||||
tags: parts.map (t => t.toLowerCase ())
|
|
||||||
.filter (t => t !== 'type:or')
|
|
||||||
.join (' '),
|
|
||||||
match })).toString () }`)
|
|
||||||
setSuggestionsVsbl (false)
|
|
||||||
setRequest ({ query: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (compositionFlgRef.current
|
|
||||||
|| ev.nativeEvent.isComposing
|
|
||||||
|| ev.nativeEvent.keyCode === 229)
|
|
||||||
{
|
|
||||||
if (ev.key === 'Enter')
|
|
||||||
ev.preventDefault ()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (ev.key)
|
switch (ev.key)
|
||||||
{
|
{
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
@@ -118,12 +90,13 @@ const TagSearch: FC = () => {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
|
if (activeIndex < 0)
|
||||||
|
break
|
||||||
|
ev.preventDefault ()
|
||||||
{
|
{
|
||||||
const selected = suggestionsVsbl ? suggestions[activeIndex] : null
|
const selected = suggestions[activeIndex]
|
||||||
if (selected == null)
|
if (selected)
|
||||||
break
|
handleTagSelect (selected)
|
||||||
ev.preventDefault ()
|
|
||||||
handleTagSelect (selected)
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -132,6 +105,20 @@ const TagSearch: FC = () => {
|
|||||||
setSuggestionsVsbl (false)
|
setSuggestionsVsbl (false)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if (ev.key === 'Enter' && (!(suggestionsVsbl) || activeIndex < 0))
|
||||||
|
{
|
||||||
|
const parts = search.split (' ')
|
||||||
|
const match = parts.map (t => t.toLowerCase ()).includes ('type:or') ? 'any' : 'all'
|
||||||
|
navigate (`/posts?${
|
||||||
|
(new URLSearchParams ({
|
||||||
|
tags: parts.map (t => t.toLowerCase ())
|
||||||
|
.filter (t => t !== 'type:or')
|
||||||
|
.join (' '),
|
||||||
|
match }))
|
||||||
|
.toString () }`)
|
||||||
|
setSuggestionsVsbl (false)
|
||||||
|
setRequest ({ query: '' })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTagSelect = (tag: Tag) => {
|
const handleTagSelect = (tag: Tag) => {
|
||||||
@@ -157,9 +144,8 @@ const TagSearch: FC = () => {
|
|||||||
}, [location.search])
|
}, [location.search])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="relative w-full">
|
<div className="relative w-full">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
enterKeyHint="search"
|
|
||||||
data-shortcut-focus-search="true"
|
data-shortcut-focus-search="true"
|
||||||
placeholder={message ('labels.tagSearch').text}
|
placeholder={message ('labels.tagSearch').text}
|
||||||
value={search}
|
value={search}
|
||||||
@@ -167,18 +153,12 @@ const TagSearch: FC = () => {
|
|||||||
onFocus={() => setSuggestionsVsbl (true)}
|
onFocus={() => setSuggestionsVsbl (true)}
|
||||||
onBlur={() => setSuggestionsVsbl (false)}
|
onBlur={() => setSuggestionsVsbl (false)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onCompositionStart={() => {
|
|
||||||
compositionFlgRef.current = true
|
|
||||||
}}
|
|
||||||
onCompositionEnd={() => {
|
|
||||||
compositionFlgRef.current = false
|
|
||||||
}}
|
|
||||||
className={inputClass (false,
|
className={inputClass (false,
|
||||||
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
'px-3 py-2 dark:border-gray-600 dark:bg-gray-800 dark:text-white')}/>
|
||||||
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
<TagSearchBox suggestions={suggestionsVsbl && suggestions.length ? suggestions : [] as Tag[]}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onSelect={handleTagSelect}/>
|
onSelect={handleTagSelect}/>
|
||||||
</form>)
|
</div>)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TagSearch
|
export default TagSearch
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Globe } from 'lucide-react'
|
|||||||
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
import { useLocale, useLocaleNavigate, useVerticalPreview } from '@/lib/useLocale'
|
import { useLocale, useLocaleNavigate } from '@/lib/useLocale'
|
||||||
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
import LocalisedText, { formatUIString } from '@/components/LocalisedText'
|
||||||
import Separator from '@/components/MenuSeparator'
|
import Separator from '@/components/MenuSeparator'
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
@@ -137,7 +137,6 @@ export const menuOutline = (
|
|||||||
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
||||||
const { message, label } = useUITranslation ('shared')
|
const { message, label } = useUITranslation ('shared')
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const verticalPreview = useVerticalPreview ()
|
|
||||||
const localeName = locales.find (entry => entry.code === locale)?.name
|
const localeName = locales.find (entry => entry.code === locale)?.name
|
||||||
const navigate = useLocaleNavigate ()
|
const navigate = useLocaleNavigate ()
|
||||||
const location = useLocation ()
|
const location = useLocation ()
|
||||||
@@ -181,12 +180,11 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
let node: HTMLElement | null = el
|
let node: HTMLElement | null = el
|
||||||
while (node != null && node !== nav)
|
while (node != null && node !== nav)
|
||||||
{
|
{
|
||||||
left += verticalPreview ? node.offsetTop : node.offsetLeft
|
left += node.offsetLeft
|
||||||
const parent: Element | null = node.offsetParent
|
const parent: Element | null = node.offsetParent
|
||||||
node = parent instanceof HTMLElement ? parent : null
|
node = parent instanceof HTMLElement ? parent : null
|
||||||
}
|
}
|
||||||
const width = node === nav
|
const width = node === nav ? el.offsetWidth : 0
|
||||||
? (verticalPreview ? el.offsetHeight : el.offsetWidth) : 0
|
|
||||||
const visible = width > 0
|
const visible = width > 0
|
||||||
setHL (current =>
|
setHL (current =>
|
||||||
(current.left === left && current.width === width && current.visible === visible)
|
(current.left === left && current.width === width && current.visible === visible)
|
||||||
@@ -204,7 +202,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
const [moreVsbl, setMoreVsbl] = useState (false)
|
const [moreVsbl, setMoreVsbl] = useState (false)
|
||||||
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
const [openItemIdx, setOpenItemIdx] = useState (-1)
|
||||||
const [submenuHeight, setSubmenuHeight] = useState (0)
|
const [submenuHeight, setSubmenuHeight] = useState (0)
|
||||||
const [submenuWidth, setSubmenuWidth] = useState (0)
|
|
||||||
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ())
|
||||||
|
|
||||||
const wikiIdStr = String (wikiId ?? '')
|
const wikiIdStr = String (wikiId ?? '')
|
||||||
@@ -258,22 +255,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
animationsOff
|
animationsOff
|
||||||
? undefined
|
? undefined
|
||||||
: (reducedAnimations
|
: (reducedAnimations
|
||||||
? 'transition-[transform,width,height] duration-75 ease-linear'
|
? 'transition-[transform,width] duration-75 ease-linear'
|
||||||
: 'transition-[transform,width,height] duration-200 ease-out')
|
: 'transition-[transform,width] 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 }) }
|
|
||||||
|
|
||||||
const prevActiveIdxRef = useRef<number> (activeIdx)
|
const prevActiveIdxRef = useRef<number> (activeIdx)
|
||||||
|
|
||||||
@@ -304,14 +287,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (verticalPreview)
|
|
||||||
{
|
|
||||||
setDesktop (true)
|
|
||||||
setDensity ('auto')
|
|
||||||
measure (moreVsbl ? -1 : activeIdx)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure the same desktop elements while the mobile layout is active.
|
// Measure the same desktop elements while the mobile layout is active.
|
||||||
layout.setAttribute ('data-measuring', '')
|
layout.setAttribute ('data-measuring', '')
|
||||||
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
const previousDensity = layout.getAttribute ('data-density') ?? 'auto'
|
||||||
@@ -376,8 +351,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
removeEventListener ('resize', schedule)
|
removeEventListener ('resize', schedule)
|
||||||
document.fonts?.removeEventListener ('loadingdone', schedule)
|
document.fonts?.removeEventListener ('loadingdone', schedule)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales,
|
}, [activeIdx, moreVsbl, desktop, density, user?.name, locale, locales, menuLabels])
|
||||||
menuLabels, verticalPreview])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (desktop == null || layoutReady)
|
if (desktop == null || layoutReady)
|
||||||
@@ -404,8 +378,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
measure (moreVsbl ? -1 : activeIdx)
|
measure (moreVsbl ? -1 : activeIdx)
|
||||||
if (!(desktop))
|
if (!(desktop))
|
||||||
setMoreVsbl (false)
|
setMoreVsbl (false)
|
||||||
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode,
|
}, [activeIdx, moreVsbl, desktop, density, locale, animationMode, menuLabels])
|
||||||
menuLabels, verticalPreview])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
const unsubscribe = WikiIdBus.subscribe (setWikiId)
|
||||||
@@ -419,7 +392,6 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
|
|
||||||
const update = () => {
|
const update = () => {
|
||||||
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
setSubmenuHeight (Math.ceil (content.getBoundingClientRect ().height))
|
||||||
setSubmenuWidth (Math.ceil (content.getBoundingClientRect ().width))
|
|
||||||
}
|
}
|
||||||
const observer =
|
const observer =
|
||||||
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
typeof ResizeObserver === 'undefined' ? null : new ResizeObserver (update)
|
||||||
@@ -431,7 +403,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
observer?.disconnect ()
|
observer?.disconnect ()
|
||||||
removeEventListener ('resize', update)
|
removeEventListener ('resize', update)
|
||||||
}
|
}
|
||||||
}, [activeIdx, moreVsbl, desktop, density, locale, verticalPreview])
|
}, [activeIdx, moreVsbl, desktop, density, locale])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
setMenuOpen (false)
|
setMenuOpen (false)
|
||||||
@@ -507,11 +479,8 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
className={cn ('absolute inset-y-0 h-12',
|
className={cn ('absolute inset-y-0 h-12',
|
||||||
'top-nav-highlight',
|
'top-nav-highlight',
|
||||||
highlightTransitionClass)}
|
highlightTransitionClass)}
|
||||||
style={{ width: verticalPreview ? '100%' : hl.width,
|
style={{ width: hl.width,
|
||||||
height: verticalPreview ? hl.width : undefined,
|
transform: `translateX(${ hl.left }px)`,
|
||||||
transform: verticalPreview
|
|
||||||
? `translateY(${ hl.left }px)`
|
|
||||||
: `translateX(${ hl.left }px)`,
|
|
||||||
opacity: hl.visible ? 1 : 0 }}/>
|
opacity: hl.visible ? 1 : 0 }}/>
|
||||||
|
|
||||||
{visibleMenu.map ((item, i) => (
|
{visibleMenu.map ((item, i) => (
|
||||||
@@ -560,18 +529,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
<div className="top-nav-account ml-auto flex min-w-0 items-center gap-2">
|
||||||
<TopNavUser user={user}/>
|
<TopNavUser user={user}/>
|
||||||
{localeSelector}
|
{localeSelector}
|
||||||
{verticalPreview && (
|
|
||||||
<span className="vertical-preview-sample" lang="mn-Mong">
|
|
||||||
ᠮᠣᠩᠭᠣᠯ
|
|
||||||
</span>)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
<div className="top-nav-mobile-controls ml-auto flex shrink-0 items-center gap-2">
|
||||||
{menuOpen && localeSelector}
|
{menuOpen && localeSelector}
|
||||||
{verticalPreview && (
|
|
||||||
<span className="vertical-preview-sample" lang="mn-Mong">
|
|
||||||
ᠮᠣᠩᠭᠣᠯ
|
|
||||||
</span>)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
className="top-nav-mobile-toggle shrink-0 whitespace-nowrap
|
||||||
@@ -593,9 +554,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
layout
|
layout
|
||||||
className="top-nav-desktop-submenu relative z-20 shrink-0
|
className="top-nav-desktop-submenu relative z-20 shrink-0
|
||||||
overflow-hidden top-nav-submenu"
|
overflow-hidden top-nav-submenu"
|
||||||
animate={verticalPreview && desktop
|
animate={{ height: desktop == null ? 'auto' : submenuHeight }}
|
||||||
? { width: submenuWidth, height: '100%' }
|
|
||||||
: { width: 'auto', height: desktop == null ? 'auto' : submenuHeight }}
|
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
if (moreVsbl)
|
if (moreVsbl)
|
||||||
setMoreVsbl (false)
|
setMoreVsbl (false)
|
||||||
@@ -607,8 +566,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
onLayoutAnimationComplete={() => {
|
onLayoutAnimationComplete={() => {
|
||||||
measure (highlightTargetRef.current)
|
measure (highlightTargetRef.current)
|
||||||
}}>
|
}}>
|
||||||
<div ref={submenuContentRef}
|
<div ref={submenuContentRef} className={cn (moreVsbl && 'top-nav-more-menu')}>
|
||||||
className={cn ('top-nav-submenu-content', moreVsbl && 'top-nav-more-menu')}>
|
|
||||||
{moreVsbl
|
{moreVsbl
|
||||||
? (
|
? (
|
||||||
moreMenu.map ((item, i) => (
|
moreMenu.map ((item, i) => (
|
||||||
@@ -618,11 +576,9 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
{...((item.visible ?? true)
|
{...((item.visible ?? true)
|
||||||
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
? { layoutId: `menu-${ menu.indexOf (item) }` }
|
||||||
: (animationsOff ? { }
|
: (animationsOff ? { }
|
||||||
: { initial: {
|
: { initial: { x: 40, y: -40, opacity: 0 },
|
||||||
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 },
|
|
||||||
animate: { x: 0, y: 0, opacity: 1 },
|
animate: { x: 0, y: 0, opacity: 1 },
|
||||||
exit: {
|
exit: { x: 40, y: -40, opacity: 0 } }))}
|
||||||
x: 40, y: verticalPreview ? 0 : -40, opacity: 0 } }))}
|
|
||||||
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
onLayoutAnimationComplete={() => measure (highlightTargetRef.current)}
|
||||||
className="z-10 min-w-0 px-3 py-2 font-bold">
|
className="z-10 min-w-0 px-3 py-2 font-bold">
|
||||||
<h2>
|
<h2>
|
||||||
@@ -643,7 +599,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(visibleMenu[activeIdx] === item
|
{...(visibleMenu[activeIdx] === item
|
||||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
? { 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}
|
{subItem.component}
|
||||||
</motion.div>)
|
</motion.div>)
|
||||||
: (
|
: (
|
||||||
@@ -653,7 +612,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
transition={topNavTransition}
|
transition={topNavTransition}
|
||||||
{...(visibleMenu[activeIdx] === item
|
{...(visibleMenu[activeIdx] === item
|
||||||
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
? { layoutId: `submenu-${ menu.indexOf (item) }-${ j }` }
|
||||||
: submenuItemMotion)}>
|
: (animationsOff ? { }
|
||||||
|
: { initial: { y: -40, opacity: 0 },
|
||||||
|
animate: { y: 0, opacity: 1 },
|
||||||
|
exit: { y: -40, opacity: 0 } }))}>
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={subItem.to}
|
to={subItem.to}
|
||||||
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
|
||||||
@@ -671,8 +633,7 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<div className="relative min-h-[40px]">
|
<div className="relative min-h-[40px]">
|
||||||
{animationsOff
|
{animationsOff
|
||||||
? (
|
? (
|
||||||
<div className="top-nav-submenu-items flex min-h-[40px]
|
<div className="flex min-h-[40px] flex-wrap items-center px-3">
|
||||||
flex-wrap items-center px-3">
|
|
||||||
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
{(visibleMenu[activeIdx]?.subMenu ?? [])
|
||||||
.filter (item => item.visible ?? true)
|
.filter (item => item.visible ?? true)
|
||||||
.map ((item, i) => (
|
.map ((item, i) => (
|
||||||
@@ -701,9 +662,10 @@ const TopNav: FC<Props> = ({ user, locales, resolveLocaleSwitch }) => {
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={activeIdx}
|
key={activeIdx}
|
||||||
custom={dir}
|
custom={dir}
|
||||||
variants={submenuVariants}
|
variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
|
||||||
className="top-nav-submenu-items relative flex min-h-[40px]
|
centre: { y: 0, opacity: 1 },
|
||||||
flex-wrap items-center px-3"
|
exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
|
||||||
|
className="relative flex min-h-[40px] flex-wrap items-center px-3"
|
||||||
initial="enter"
|
initial="enter"
|
||||||
animate="centre"
|
animate="centre"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { useLocation } from 'react-router-dom'
|
|
||||||
|
|
||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { useVerticalPreview } from '@/lib/useLocale'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import type { FC, ReactNode } from 'react'
|
import type { FC, ReactNode } from 'react'
|
||||||
@@ -12,71 +9,21 @@ import type { FC, ReactNode } from 'react'
|
|||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
animateLayout?: boolean
|
animateLayout?: boolean }
|
||||||
verticalContent?: boolean }
|
|
||||||
|
|
||||||
|
|
||||||
const MainArea: FC<Props> = (
|
const MainArea: FC<Props> = ({ children, className, animateLayout = true }) => {
|
||||||
{ children, className, animateLayout = true, verticalContent = false },
|
|
||||||
) => {
|
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
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 animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
animationMode,
|
animationMode,
|
||||||
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
{ 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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.main
|
<motion.main
|
||||||
ref={rootRef}
|
|
||||||
data-vertical-content={horizontal || undefined}
|
|
||||||
tabIndex={horizontal ? 0 : undefined}
|
|
||||||
transition={{ layout: layoutTransition }}
|
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'}>
|
layout={(animationMode === 'off' || !animateLayout) ? false : 'position'}>
|
||||||
{children}
|
{children}
|
||||||
</motion.main>)
|
</motion.main>)
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
|
|
||||||
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
import { clientAnimationTransition } from '@/lib/clientAnimation'
|
||||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { useVerticalPreview } from '@/lib/useLocale'
|
|
||||||
|
|
||||||
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
import type { CSSProperties, FC, MouseEvent, PointerEvent, ReactNode } from 'react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
type SidebarSide = 'left' | 'right'
|
type SidebarSide = 'left' | 'right'
|
||||||
|
|
||||||
@@ -24,12 +23,12 @@ type Props = {
|
|||||||
|
|
||||||
type ResizeState = {
|
type ResizeState = {
|
||||||
pointerId: number
|
pointerId: number
|
||||||
startSize: number
|
startWidth: number
|
||||||
startX: number
|
startX: number
|
||||||
startY: number
|
|
||||||
verticalPreview: boolean
|
|
||||||
side: SidebarSide }
|
side: SidebarSide }
|
||||||
|
|
||||||
|
type PendingPressState = ResizeState & { startY: number }
|
||||||
|
|
||||||
const DEFAULT_SIDEBAR_WIDTH = 256
|
const DEFAULT_SIDEBAR_WIDTH = 256
|
||||||
const MIN_SIDEBAR_WIDTH = 192
|
const MIN_SIDEBAR_WIDTH = 192
|
||||||
const MIN_MAIN_WIDTH = 360
|
const MIN_MAIN_WIDTH = 360
|
||||||
@@ -38,97 +37,84 @@ const LONG_PRESS_DELAY_MS = 350
|
|||||||
const TOUCH_MOVE_THRESHOLD_PX = 8
|
const TOUCH_MOVE_THRESHOLD_PX = 8
|
||||||
|
|
||||||
|
|
||||||
const storageKeyForSidebar = (
|
const storageKeyForSidebar = (sidebarKey: string, side: SidebarSide): string =>
|
||||||
sidebarKey: string,
|
`sidebar:${ sidebarKey }:${ side }:width`
|
||||||
side: SidebarSide,
|
|
||||||
verticalPreview = false,
|
|
||||||
): string =>
|
|
||||||
`sidebar:${ sidebarKey }:${ side }:${ verticalPreview ? 'height' : 'width' }`
|
|
||||||
|
|
||||||
|
|
||||||
const getSidebarMaxWidth = (containerSize: number): number => {
|
const getSidebarMaxWidth = (containerWidth: number): number => {
|
||||||
const viewportWidth =
|
const viewportWidth =
|
||||||
typeof window === 'undefined' ? containerSize : window.innerWidth
|
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
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)
|
const dynamicMaxWidth = Math.min (viewportMaxWidth, containerMaxWidth)
|
||||||
|
|
||||||
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
|
return Math.max (MIN_SIDEBAR_WIDTH, dynamicMaxWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const readStoredSidebarSize = (
|
const readStoredSidebarWidth = (
|
||||||
sidebarKey: string,
|
sidebarKey: string,
|
||||||
side: SidebarSide,
|
side: SidebarSide): number => {
|
||||||
verticalPreview: boolean,
|
|
||||||
): number => {
|
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return DEFAULT_SIDEBAR_WIDTH
|
return DEFAULT_SIDEBAR_WIDTH
|
||||||
|
|
||||||
try
|
const value = window.localStorage.getItem (storageKeyForSidebar (sidebarKey, side))
|
||||||
{
|
const width = value == null ? Number.NaN : Number (value)
|
||||||
const value = window.localStorage.getItem (
|
|
||||||
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
return Number.isFinite (width) ? width : DEFAULT_SIDEBAR_WIDTH
|
||||||
const size = value == null ? Number.NaN : Number (value)
|
|
||||||
return Number.isFinite (size) ? size : DEFAULT_SIDEBAR_WIDTH
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return DEFAULT_SIDEBAR_WIDTH
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const SidebarComponent: FC<Props> = (
|
const SidebarComponent: FC<Props> = ({
|
||||||
{ children, className, maxWidth, maxWidthRatio, animateLayout = true,
|
children,
|
||||||
onWidthChange, sidebarKey, side = 'left' },
|
className,
|
||||||
) => {
|
maxWidth,
|
||||||
|
maxWidthRatio,
|
||||||
|
animateLayout = true,
|
||||||
|
onWidthChange,
|
||||||
|
sidebarKey,
|
||||||
|
side = 'left',
|
||||||
|
}) => {
|
||||||
const { message } = useUITranslation ('shared')
|
const { message } = useUITranslation ('shared')
|
||||||
const behaviourSettings = useClientBehaviourSettings ()
|
const behaviourSettings = useClientBehaviourSettings ()
|
||||||
const verticalPreview = useVerticalPreview ()
|
|
||||||
const animationMode = behaviourSettings.animation ?? 'normal'
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
||||||
const layoutTransition = clientAnimationTransition (
|
const layoutTransition = clientAnimationTransition (
|
||||||
animationMode,
|
animationMode,
|
||||||
{ normal: { duration: .2, ease: 'easeOut' as const } })
|
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
||||||
|
)
|
||||||
const rootRef = useRef<HTMLDivElement | null> (null)
|
const rootRef = useRef<HTMLDivElement | null> (null)
|
||||||
const maxWidthRef = useRef (maxWidth)
|
const maxWidthRef = useRef (maxWidth)
|
||||||
const maxWidthRatioRef = useRef (maxWidthRatio)
|
const maxWidthRatioRef = useRef (maxWidthRatio)
|
||||||
const getContainerSize = useCallback (() => {
|
const getContainerWidth = useCallback (() => {
|
||||||
const container = rootRef.current?.closest ('[data-sidebar-container]')
|
const containerWidth = rootRef.current
|
||||||
return verticalPreview
|
?.closest ('[data-sidebar-container]')
|
||||||
? container?.clientHeight ?? window.innerHeight
|
?.clientWidth
|
||||||
: container?.clientWidth ?? window.innerWidth
|
?? window.innerWidth
|
||||||
}, [verticalPreview])
|
return containerWidth
|
||||||
const getClampedSize = useCallback ((
|
}, [])
|
||||||
nextSize: number,
|
const getClampedWidth = useCallback ((
|
||||||
containerSize: number,
|
nextWidth: number,
|
||||||
): number => {
|
containerWidth: 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 viewportWidth =
|
const viewportWidth =
|
||||||
typeof window === 'undefined' ? containerSize : window.innerWidth
|
typeof window === 'undefined' ? containerWidth : window.innerWidth
|
||||||
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
const viewportMaxWidth = Math.floor (viewportWidth * MAX_VIEWPORT_WIDTH_RATIO)
|
||||||
const ratioMaxWidth = maxWidthRatioRef.current == null
|
const ratioMaxWidth = maxWidthRatioRef.current == null
|
||||||
? Infinity : Math.floor (containerSize * maxWidthRatioRef.current)
|
? Infinity : Math.floor (containerWidth * maxWidthRatioRef.current)
|
||||||
const configuredMaxWidth = maxWidthRef.current == null
|
const configuredMaxWidth = maxWidthRef.current == null
|
||||||
? getSidebarMaxWidth (containerSize)
|
? getSidebarMaxWidth (containerWidth)
|
||||||
: Math.max (MIN_SIDEBAR_WIDTH,
|
: Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
Math.min (viewportMaxWidth, maxWidthRef.current))
|
Math.min (viewportMaxWidth, maxWidthRef.current))
|
||||||
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
const maxAllowedWidth = Math.max (MIN_SIDEBAR_WIDTH,
|
||||||
Math.min (ratioMaxWidth, configuredMaxWidth))
|
Math.min (ratioMaxWidth, configuredMaxWidth))
|
||||||
|
|
||||||
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextSize))
|
return Math.min (maxAllowedWidth, Math.max (MIN_SIDEBAR_WIDTH, nextWidth))
|
||||||
}, [verticalPreview])
|
}, [])
|
||||||
|
|
||||||
const [size, setSize] = useState (DEFAULT_SIDEBAR_WIDTH)
|
const [width, setWidth] = useState (DEFAULT_SIDEBAR_WIDTH)
|
||||||
const [resizing, setResizing] = useState (false)
|
const [resizing, setResizing] = useState (false)
|
||||||
const sizeRef = useRef (size)
|
const widthRef = useRef (width)
|
||||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null> (null)
|
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 resizeRef = useRef<ResizeState | null> (null)
|
||||||
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
|
const bodyStyleRef = useRef<{ cursor: string, userSelect: string } | null> (null)
|
||||||
|
|
||||||
@@ -162,7 +148,7 @@ const SidebarComponent: FC<Props> = (
|
|||||||
userSelect: document.body.style.userSelect }
|
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'
|
document.body.style.userSelect = 'none'
|
||||||
}, [clearLongPressTimer])
|
}, [clearLongPressTimer])
|
||||||
|
|
||||||
@@ -173,50 +159,43 @@ const SidebarComponent: FC<Props> = (
|
|||||||
const state = resizeRef.current
|
const state = resizeRef.current
|
||||||
resizeRef.current = null
|
resizeRef.current = null
|
||||||
|
|
||||||
try
|
if (state != null)
|
||||||
{
|
window.localStorage.setItem (
|
||||||
if (state != null)
|
storageKeyForSidebar (sidebarKey, state.side),
|
||||||
window.localStorage.setItem (
|
String (widthRef.current))
|
||||||
storageKeyForSidebar (sidebarKey, state.side, state.verticalPreview),
|
|
||||||
String (sizeRef.current))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Resizing remains usable when browser storage is unavailable.
|
|
||||||
}
|
|
||||||
|
|
||||||
setResizing (false)
|
setResizing (false)
|
||||||
restoreBodyStyle ()
|
restoreBodyStyle ()
|
||||||
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
|
}, [clearLongPressTimer, restoreBodyStyle, sidebarKey])
|
||||||
|
|
||||||
const applySize = useCallback ((nextSize: number) => {
|
const applyWidth = useCallback ((nextWidth: number) => {
|
||||||
if (nextSize === sizeRef.current)
|
if (nextWidth === widthRef.current)
|
||||||
return
|
return
|
||||||
|
|
||||||
sizeRef.current = nextSize
|
widthRef.current = nextWidth
|
||||||
setSize (nextSize)
|
setWidth (nextWidth)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
maxWidthRef.current = maxWidth
|
maxWidthRef.current = maxWidth
|
||||||
maxWidthRatioRef.current = maxWidthRatio
|
maxWidthRatioRef.current = maxWidthRatio
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
}, [applySize, getClampedSize, getContainerSize, maxWidth, maxWidthRatio])
|
}, [applyWidth, getClampedWidth, getContainerWidth, maxWidth, maxWidthRatio])
|
||||||
|
|
||||||
const updateSize = useCallback ((clientX: number, clientY: number) => {
|
const updateWidth = useCallback ((clientX: number) => {
|
||||||
const state = resizeRef.current
|
const state = resizeRef.current
|
||||||
if (state == null)
|
if (state == null)
|
||||||
return
|
return
|
||||||
|
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const delta = state.verticalPreview
|
const delta = state.side === 'left'
|
||||||
? clientY - state.startY
|
? clientX - state.startX
|
||||||
: (state.side === 'left' ? clientX - state.startX : state.startX - clientX)
|
: state.startX - clientX
|
||||||
const nextSize = getClampedSize (state.startSize + delta, containerSize)
|
const nextWidth = getClampedWidth (state.startWidth + delta, containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
}, [applySize, getClampedSize, getContainerSize])
|
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||||
|
|
||||||
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (ev: PointerEvent<HTMLDivElement>) => {
|
||||||
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
|
if (ev.pointerType !== 'mouse' && ev.pointerType !== 'touch')
|
||||||
@@ -232,10 +211,8 @@ const SidebarComponent: FC<Props> = (
|
|||||||
const state = {
|
const state = {
|
||||||
pointerId: ev.pointerId,
|
pointerId: ev.pointerId,
|
||||||
side,
|
side,
|
||||||
startSize: sizeRef.current,
|
startWidth: widthRef.current,
|
||||||
startX: ev.clientX,
|
startX: ev.clientX }
|
||||||
startY: ev.clientY,
|
|
||||||
verticalPreview }
|
|
||||||
|
|
||||||
if (ev.pointerType === 'mouse')
|
if (ev.pointerType === 'mouse')
|
||||||
{
|
{
|
||||||
@@ -243,7 +220,7 @@ const SidebarComponent: FC<Props> = (
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingPressRef.current = state
|
pendingPressRef.current = { ...state, startY: ev.clientY }
|
||||||
clearLongPressTimer ()
|
clearLongPressTimer ()
|
||||||
longPressTimerRef.current = setTimeout (() => {
|
longPressTimerRef.current = setTimeout (() => {
|
||||||
if (pendingPressRef.current != null)
|
if (pendingPressRef.current != null)
|
||||||
@@ -257,7 +234,7 @@ const SidebarComponent: FC<Props> = (
|
|||||||
if (resizeRef.current != null)
|
if (resizeRef.current != null)
|
||||||
{
|
{
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
updateSize (ev.clientX, ev.clientY)
|
updateWidth (ev.clientX)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,58 +262,58 @@ const SidebarComponent: FC<Props> = (
|
|||||||
finishResize ()
|
finishResize ()
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetSize = useCallback (() => {
|
const resetWidth = useCallback (() => {
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextSize = getClampedSize (DEFAULT_SIDEBAR_WIDTH, containerSize)
|
const nextWidth = getClampedWidth (DEFAULT_SIDEBAR_WIDTH, containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
window.localStorage.removeItem (
|
window.localStorage.removeItem (storageKeyForSidebar (sidebarKey, side))
|
||||||
storageKeyForSidebar (sidebarKey, side, verticalPreview))
|
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
||||||
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
|
||||||
|
|
||||||
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
|
const handleDoubleClick = (ev: MouseEvent<HTMLDivElement>) => {
|
||||||
ev.preventDefault ()
|
ev.preventDefault ()
|
||||||
ev.stopPropagation ()
|
ev.stopPropagation ()
|
||||||
resetSize ()
|
resetWidth ()
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextSize = getClampedSize (
|
const nextWidth = getClampedWidth (
|
||||||
readStoredSidebarSize (sidebarKey, side, verticalPreview),
|
readStoredSidebarWidth (sidebarKey, side),
|
||||||
containerSize)
|
containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
}, [applySize, getClampedSize, getContainerSize, sidebarKey, side, verticalPreview])
|
}, [applyWidth, getClampedWidth, getContainerWidth, sidebarKey, side])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
}, [applySize, getClampedSize, getContainerSize])
|
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (typeof window === 'undefined')
|
if (typeof window === 'undefined')
|
||||||
return
|
return
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
const containerSize = getContainerSize ()
|
const containerWidth = getContainerWidth ()
|
||||||
const nextSize = getClampedSize (sizeRef.current, containerSize)
|
const nextWidth = getClampedWidth (widthRef.current, containerWidth)
|
||||||
applySize (nextSize)
|
applyWidth (nextWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener ('resize', handleResize)
|
window.addEventListener ('resize', handleResize)
|
||||||
return () => window.removeEventListener ('resize', handleResize)
|
return () => window.removeEventListener ('resize', handleResize)
|
||||||
}, [applySize, getClampedSize, getContainerSize])
|
}, [applyWidth, getClampedWidth, getContainerWidth])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!verticalPreview)
|
onWidthChange?.(width)
|
||||||
onWidthChange?.(size)
|
}, [onWidthChange, width])
|
||||||
}, [onWidthChange, size, verticalPreview])
|
|
||||||
|
|
||||||
useEffect (() => () => finishResize (), [finishResize, verticalPreview])
|
useEffect (() => () => {
|
||||||
|
clearLongPressTimer ()
|
||||||
|
restoreBodyStyle ()
|
||||||
|
}, [clearLongPressTimer, restoreBodyStyle])
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
'--sidebar-width': `${ size }px`,
|
'--sidebar-width': `${ width }px` } as CSSProperties
|
||||||
'--sidebar-height': `${ size }px` } as CSSProperties
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -345,7 +322,7 @@ const SidebarComponent: FC<Props> = (
|
|||||||
transition={{ layout: layoutTransition }}
|
transition={{ layout: layoutTransition }}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn (
|
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)}>
|
className)}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<style>
|
<style>
|
||||||
@@ -368,16 +345,16 @@ const SidebarComponent: FC<Props> = (
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
role="separator"
|
role="separator"
|
||||||
aria-orientation={verticalPreview ? 'horizontal' : 'vertical'}
|
aria-orientation="vertical"
|
||||||
aria-label={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').text}
|
aria-label={message ('sidebarWidth').text}
|
||||||
lang={message (verticalPreview ? 'labels.menu' : 'sidebarWidth').language}
|
lang={message ('sidebarWidth').language}
|
||||||
onPointerDown={handlePointerDown}
|
onPointerDown={handlePointerDown}
|
||||||
onPointerMove={handlePointerMove}
|
onPointerMove={handlePointerMove}
|
||||||
onPointerUp={handlePointerEnd}
|
onPointerUp={handlePointerEnd}
|
||||||
onPointerCancel={handlePointerEnd}
|
onPointerCancel={handlePointerEnd}
|
||||||
onDoubleClick={handleDoubleClick}
|
onDoubleClick={handleDoubleClick}
|
||||||
className={cn (
|
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',
|
'touch-none select-none',
|
||||||
side === 'left' ? '-right-2' : '-left-2',
|
side === 'left' ? '-right-2' : '-left-2',
|
||||||
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
'before:absolute before:top-0 before:bottom-0 before:w-px',
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { createStaticHandler, createStaticRouter,
|
|||||||
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
import { AppFrame, appRoutes, resolvePageRoute } from '@/App'
|
||||||
import { apiGet, isApiError } from '@/lib/api'
|
import { apiGet, isApiError } from '@/lib/api'
|
||||||
import { createUII18n } from '@/lib/i18n'
|
import { createUII18n } from '@/lib/i18n'
|
||||||
import { fetchLocales, isLocaleCode, isVerticalPreviewPath,
|
import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales'
|
||||||
legacyLocalePath } from '@/lib/locales'
|
|
||||||
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { withRenderContext } from '@/lib/renderContext.server'
|
import { withRenderContext } from '@/lib/renderContext.server'
|
||||||
|
|
||||||
@@ -73,15 +72,13 @@ export const render = async (
|
|||||||
pathname => resolvePageRoute (pathname) != null)
|
pathname => resolvePageRoute (pathname) != null)
|
||||||
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname
|
||||||
const segment = pathname.split ('/')[1]
|
const segment = pathname.split ('/')[1]
|
||||||
const verticalPreview = isVerticalPreviewPath (pathname)
|
|
||||||
const known = resolvePageRoute (pathname)
|
const known = resolvePageRoute (pathname)
|
||||||
if (legacy != null && known != null)
|
if (legacy != null && known != null)
|
||||||
return { status: 308, location: legacy }
|
return { status: 308, location: legacy }
|
||||||
const locale = isLocaleCode (segment) ? segment : 'ja'
|
const locale = isLocaleCode (segment) ? segment : 'ja'
|
||||||
const routePrefix = verticalPreview ? 'mn-Mong' : locale
|
|
||||||
if (known?.route.handle?.redirectTo != null)
|
if (known?.route.handle?.redirectTo != null)
|
||||||
return { status: 308,
|
return { status: 308,
|
||||||
location: `/${routePrefix}${known.route.handle.redirectTo}${url.search}` }
|
location: `/${locale}${known.route.handle.redirectTo}${url.search}` }
|
||||||
const requestContext: RenderContext & { status: number } = {
|
const requestContext: RenderContext & { status: number } = {
|
||||||
pathname: url.pathname, origin, apiBaseURL,
|
pathname: url.pathname, origin, apiBaseURL,
|
||||||
...authentication, status: 200 }
|
...authentication, status: 200 }
|
||||||
@@ -109,8 +106,7 @@ export const render = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prepare = known?.route.handle?.prepare
|
const prepare = known?.route.handle?.prepare
|
||||||
const routeLocaleValid = isLocaleCode (segment) || verticalPreview
|
let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200
|
||||||
let status = known == null || !active || !routeLocaleValid ? 404 : 200
|
|
||||||
if (status === 200 && known?.route.handle?.authentication != null
|
if (status === 200 && known?.route.handle?.authentication != null
|
||||||
&& client.getQueryData (sessionKeys.current) == null)
|
&& client.getQueryData (sessionKeys.current) == null)
|
||||||
status = 401
|
status = 401
|
||||||
|
|||||||
@@ -625,503 +625,3 @@ body
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loaded only by the development-only sample, with the OFL alongside the font. */
|
|
||||||
@font-face
|
|
||||||
{
|
|
||||||
font-family: 'BTRC Mongolian Preview';
|
|
||||||
src: url('/fonts/noto-sans-mongolian/NotoSansMongolian-Regular.ttf') format('truetype');
|
|
||||||
font-weight: 400;
|
|
||||||
font-style: normal;
|
|
||||||
font-display: swap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Development-only /mn-Mong layout. Unannotated pages retain horizontal text. */
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .sidebar-panel
|
|
||||||
{
|
|
||||||
width: 100%;
|
|
||||||
height: var(--sidebar-height);
|
|
||||||
min-height: 0;
|
|
||||||
flex: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .sidebar
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
direction: ltr;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .sidebar > *
|
|
||||||
{
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .sidebar > form
|
|
||||||
{
|
|
||||||
width: 16rem;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .sidebar > form > ul
|
|
||||||
{
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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] .vertical-preview-sample
|
|
||||||
{
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
text-orientation: mixed;
|
|
||||||
font-family: 'BTRC Mongolian Preview', 'Mongolian Baiti', sans-serif;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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-direction: column;
|
|
||||||
justify-content: flex-start;
|
|
||||||
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: horizontal-tb;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .tags-main-area
|
|
||||||
{
|
|
||||||
display: flex;
|
|
||||||
align-items: stretch;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .tag-list-filter
|
|
||||||
{
|
|
||||||
flex: none;
|
|
||||||
width: min(24rem, 75vw);
|
|
||||||
max-width: none;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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-width: 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;
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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: horizontal-tb;
|
|
||||||
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: horizontal-tb;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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: horizontal-tb;
|
|
||||||
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;
|
|
||||||
max-width: 50vw;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-bar
|
|
||||||
{
|
|
||||||
flex-direction: column;
|
|
||||||
flex: none;
|
|
||||||
width: 7rem;
|
|
||||||
height: 100%;
|
|
||||||
padding: .5rem;
|
|
||||||
gap: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-primary
|
|
||||||
{
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-brand-link
|
|
||||||
{
|
|
||||||
writing-mode: vertical-lr;
|
|
||||||
text-orientation: mixed;
|
|
||||||
max-height: 9rem;
|
|
||||||
white-space: normal;
|
|
||||||
margin-block: .5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-desktop-menu
|
|
||||||
{
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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: 9rem;
|
|
||||||
white-space: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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: horizontal-tb;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-desktop-submenu
|
|
||||||
{
|
|
||||||
min-width: 0;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] .top-nav-submenu-content
|
|
||||||
{
|
|
||||||
width: max-content;
|
|
||||||
max-width: 35vw;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-vertical-preview] :is(.top-nav-submenu-items, .top-nav-more-links)
|
|
||||||
{
|
|
||||||
flex-direction: column;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
[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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,11 +28,6 @@ export const localeFromPath = (pathname: string): LocaleCode | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// A development-only layout route; API and dictionary locales stay unchanged.
|
|
||||||
export const isVerticalPreviewPath = (pathname: string): boolean =>
|
|
||||||
import.meta.env.DEV && /^\/mn-Mong(?:[/?#]|$)/.test (pathname)
|
|
||||||
|
|
||||||
|
|
||||||
export const resolveViewingLocale = (
|
export const resolveViewingLocale = (
|
||||||
pathname: string,
|
pathname: string,
|
||||||
routeLocale?: unknown,
|
routeLocale?: unknown,
|
||||||
@@ -45,22 +40,20 @@ export const currentViewingLocale = (): LocaleCode =>
|
|||||||
|
|
||||||
|
|
||||||
export const unlocalisedPath = (pathname: string): string => {
|
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) || '/'
|
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
|
const pathname = typeof to === 'string' ? to : to.pathname
|
||||||
if (pathname == null
|
if (pathname == null
|
||||||
|| !(pathname.startsWith ('/'))
|
|| !(pathname.startsWith ('/'))
|
||||||
|| pathname.startsWith ('//')
|
|| pathname.startsWith ('//')
|
||||||
|| localeFromPath (pathname) != null
|
|| localeFromPath (pathname) != null)
|
||||||
|| isVerticalPreviewPath (pathname))
|
|
||||||
return to
|
return to
|
||||||
|
|
||||||
const prefix = isVerticalPreviewPath (viewingPath) ? 'mn-Mong' : locale
|
const localised = `/${ locale }${ pathname }`
|
||||||
const localised = `/${ prefix }${ pathname }`
|
|
||||||
return typeof to === 'string' ? localised : { ...to, pathname: localised }
|
return typeof to === 'string' ? localised : { ...to, pathname: localised }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +73,7 @@ export const legacyLocalePath = (
|
|||||||
location: { pathname: string; search: string; hash: string },
|
location: { pathname: string; search: string; hash: string },
|
||||||
isPage: (pathname: string) => boolean = () => false,
|
isPage: (pathname: string) => boolean = () => false,
|
||||||
): string | null => {
|
): string | null => {
|
||||||
if (localeFromPath (location.pathname) != null
|
if (localeFromPath (location.pathname) != null || !isPage (location.pathname))
|
||||||
|| isVerticalPreviewPath (location.pathname) || !isPage (location.pathname))
|
|
||||||
return null
|
return null
|
||||||
return switchLocalePath (location, automaticallySelectedLocale ())
|
return switchLocalePath (location, automaticallySelectedLocale ())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { isVerticalPreviewPath, localiseTo, resolveViewingLocale } from '@/lib/locales'
|
import { localiseTo, resolveViewingLocale } from '@/lib/locales'
|
||||||
|
|
||||||
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -18,15 +18,10 @@ export const useLocale = (): LocaleCode => {
|
|||||||
export const useLocaleNavigate = (): NavigateFunction => {
|
export const useLocaleNavigate = (): NavigateFunction => {
|
||||||
const navigate = useNavigate ()
|
const navigate = useNavigate ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { pathname } = useLocation ()
|
|
||||||
return useCallback ((to: To | number, options?: NavigateOptions) => {
|
return useCallback ((to: To | number, options?: NavigateOptions) => {
|
||||||
if (typeof to === 'number')
|
if (typeof to === 'number')
|
||||||
navigate (to)
|
navigate (to)
|
||||||
else
|
else
|
||||||
navigate (localiseTo (to, locale, pathname), options)
|
navigate (localiseTo (to, locale), options)
|
||||||
}, [locale, navigate, pathname])
|
}, [locale, navigate])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const useVerticalPreview = (): boolean =>
|
|
||||||
isVerticalPreviewPath (useLocation ().pathname)
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ const PostListPage: FC = () => {
|
|||||||
JSON.stringify (statesToSave))
|
JSON.stringify (statesToSave))
|
||||||
}}/>
|
}}/>
|
||||||
|
|
||||||
<MainArea className="posts-main-area" verticalContent>
|
<MainArea>
|
||||||
<TabGroup
|
<TabGroup
|
||||||
rightAddon={
|
rightAddon={
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -220,10 +220,10 @@ const TagListPage: FC = () => {
|
|||||||
}), [page, totalPages, location.search]))
|
}), [page, totalPages, location.search]))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea className="tags-main-area min-w-0" verticalContent>
|
<MainArea className="min-w-0">
|
||||||
<PageHead title={message ('title')}/>
|
<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">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<PageTitle>{label ('title')}</PageTitle>
|
<PageTitle>{label ('title')}</PageTitle>
|
||||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
<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>}
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
{loading ? label ('common:loading') : (results.length > 0 ? (
|
{loading ? label ('common:loading') : (results.length > 0 ? (
|
||||||
<div className="tag-list-results mt-4 min-w-0 max-w-full">
|
<div className="mt-4 min-w-0 max-w-full">
|
||||||
<div className="tag-list-table min-w-0 max-w-full overflow-x-auto">
|
<div className="min-w-0 max-w-full overflow-x-auto">
|
||||||
<table className="w-full min-w-[2000px] table-auto border-collapse">
|
<table className="w-full min-w-[2000px] table-auto border-collapse">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-72"/>
|
<col className="w-72"/>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const WikiDetailPage: FC = () => {
|
|||||||
return <NotFound/>
|
return <NotFound/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainArea className="wiki-main-area" verticalContent>
|
<MainArea>
|
||||||
<PageHead title={{
|
<PageHead title={{
|
||||||
text: `${displayTitle}${deprecated
|
text: `${displayTitle}${deprecated
|
||||||
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
? message ('labels.deprecatedSuffix').text : ''} Wiki`,
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする