このコミットが含まれているのは:
@@ -573,10 +573,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
- When new user-facing wording appears necessary, ask the user for the exact
|
- When new user-facing wording appears necessary, ask the user for the exact
|
||||||
wording and placement before implementing it.
|
wording and placement before implementing it.
|
||||||
- Do not invent replacement copy when removing unrequested wording.
|
- Do not invent replacement copy when removing unrequested wording.
|
||||||
- Do not create, modify, or run tests unless the user explicitly asks for
|
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
|
||||||
test work. When the user asks for tests, keep working within the permitted
|
fixture、mock、snapshot、テスト補助コードも対象とする。
|
||||||
test-file scope and rerun them until they pass or the remaining failure is
|
既存のテスト差分を勝手に修正・差戻ししない。
|
||||||
clearly blocked.
|
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
|
||||||
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
- Test-only work includes adding, updating, deleting, reorganising, or fixing
|
||||||
SyntaxError in tests. During test-only work, do not modify production code.
|
SyntaxError in tests. During test-only work, do not modify production code.
|
||||||
- During test-only work, do not change production constants, behaviour, API
|
- During test-only work, do not change production constants, behaviour, API
|
||||||
@@ -637,9 +637,9 @@ and layout reuse, follow `frontend/AGENTS.md`.
|
|||||||
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
|
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
|
||||||
`_untranslated` 規約を使い、language metadata を維持する。
|
`_untranslated` 規約を使い、language metadata を維持する。
|
||||||
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
|
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
|
||||||
- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の
|
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
|
||||||
確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。
|
SSR / hydration の整合及び翻訳キーの非露出を検証する。
|
||||||
この規約はテストコードの追加・修正を義務付けない。
|
i18n の検証を理由にテスト関連作業を開始しない。
|
||||||
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
|
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
|
||||||
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
|
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
|
||||||
アクセシビリティ用ラベルも UI 文言として扱う。
|
アクセシビリティ用ラベルも UI 文言として扱う。
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ module CountsListing
|
|||||||
if query.mode == 'summary'
|
if query.mode == 'summary'
|
||||||
result.merge!(query.summary)
|
result.merge!(query.summary)
|
||||||
else
|
else
|
||||||
result[:versions] = serialise_versions(query.details(version_scope), locale.language_code)
|
result[:versions] = CountsVersionRepr.many(
|
||||||
|
query.details, resource:, language_code: locale.language_code)
|
||||||
end
|
end
|
||||||
render json: result
|
render json: result
|
||||||
rescue ArgumentError, TypeError
|
rescue ArgumentError, TypeError
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Counts uses recorded values only; history/diff endpoints retain their own contract.
|
||||||
|
class CountsVersionRepr
|
||||||
|
def self.many(rows, resource:, language_code:)
|
||||||
|
new(rows.to_a, resource:, language_code:).serialise
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(rows, resource:, language_code:)
|
||||||
|
@rows = rows
|
||||||
|
@resource = resource
|
||||||
|
@language = language_code
|
||||||
|
@users = User.where(id: rows.map(&:created_by_user_id).compact).pluck(:id, :name).to_h
|
||||||
|
@languages = master_names(Language)
|
||||||
|
@scripts = master_names(Script)
|
||||||
|
@tag_versions = {}
|
||||||
|
load_tag_versions if resource == 'posts'
|
||||||
|
end
|
||||||
|
|
||||||
|
def serialise
|
||||||
|
@rows.map do |row|
|
||||||
|
resource_id = row.public_send(@resource == 'posts' ? :post_id : :tag_id)
|
||||||
|
common = { id: row.id, resource_id:,
|
||||||
|
version_no: row.version_no, is_latest: row.attributes['counts_latest'].to_i == 1,
|
||||||
|
created_at: row.created_at.iso8601,
|
||||||
|
created_by_user: row.created_by_user_id &&
|
||||||
|
{ id: row.created_by_user_id, name: @users[row.created_by_user_id] } }
|
||||||
|
common.merge(@resource == 'posts' ? post(row) : tag(row))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def master_names(model)
|
||||||
|
model.select('code AS counts_code', :name).to_h do |record|
|
||||||
|
[record.attributes['counts_code'], record.name]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def json(value)
|
||||||
|
value.is_a?(String) ? JSON.parse(value) : value
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_tag_versions
|
||||||
|
pairs = @rows.flat_map { Array(json(_1.tags_json)) }
|
||||||
|
.filter_map { |entry| [entry['tag_id'], entry['version_no']] if entry['tag_id'] }
|
||||||
|
.uniq
|
||||||
|
pairs.each_slice(500) do |batch|
|
||||||
|
placeholders = batch.map { '(?, ?)' }.join(', ')
|
||||||
|
TagVersion.where(["(tag_id, version_no) IN (#{ placeholders })", *batch.flatten])
|
||||||
|
.select(:tag_id, :version_no, :name, :category, :names).each do |version|
|
||||||
|
@tag_versions[[version.tag_id, version.version_no]] = version
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def names(value)
|
||||||
|
(json(value) || {}).sort.map do |language, entry|
|
||||||
|
aliases = entry.fetch('aliases', []).map do |alias_entry|
|
||||||
|
alias_entry.merge('script_name' => @scripts[alias_entry['script_code']])
|
||||||
|
end
|
||||||
|
{ language_code: language, language_name: @languages[language],
|
||||||
|
script_code: entry['script_code'], script_name: @scripts[entry['script_code']],
|
||||||
|
name: entry['name'], auto_generated: entry['auto_generated'], aliases: }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def post(row)
|
||||||
|
snapshots = Array(json(row.tags_json)).map do |entry|
|
||||||
|
version = @tag_versions[[entry['tag_id'], entry['version_no']]]
|
||||||
|
{ id: entry['tag_id'] || entry['external_tag_id'],
|
||||||
|
kind: entry['tag_id'] ? 'internal' : 'external',
|
||||||
|
tag_id: entry['tag_id'], external_tag_id: entry['external_tag_id'],
|
||||||
|
version_no: entry['version_no'], name: entry['name'] || version&.name,
|
||||||
|
category: entry['category'] || version&.category,
|
||||||
|
sections: entry.fetch('sections', []), names: version ? names(version.names) : [] }
|
||||||
|
end
|
||||||
|
{ title: row.title, url: row.url, thumbnail_base: row.thumbnail_base,
|
||||||
|
tags: snapshots, parents: row.parent_post_ids.to_s.split.map { { id: _1.to_i } },
|
||||||
|
video_ms: row.video_ms,
|
||||||
|
original_created_from: row.original_created_from&.iso8601,
|
||||||
|
original_created_before: row.original_created_before&.iso8601 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def tag(row)
|
||||||
|
recorded_names = names(row.names)
|
||||||
|
selected = recorded_names.find { _1[:language_code] == @language }
|
||||||
|
aliases = recorded_names.flat_map do |entry|
|
||||||
|
entry[:aliases].map {
|
||||||
|
_1.merge('language_code' => entry[:language_code],
|
||||||
|
'language_name' => entry[:language_name])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
{ name: selected&.fetch(:name) || row.name, names: recorded_names,
|
||||||
|
category: row.category, aliases:,
|
||||||
|
parents: row.parent_tag_ids.to_s.split.map { { id: _1.to_i } },
|
||||||
|
deprecated: row.deprecated_at&.iso8601 }
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,59 +1,71 @@
|
|||||||
# Read-only history queries. All identifiers and SQL expressions are server-owned.
|
# Read-only history queries. SQL identifiers and expressions are server-owned.
|
||||||
class CountsQuery
|
class CountsQuery
|
||||||
EVENTS = ['create', 'update', 'discard', 'restore'].freeze
|
AXES = ['month', 'user'].freeze
|
||||||
AXES = ['event_type', 'month', 'user'].freeze
|
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'user', 'created_at'].freeze
|
||||||
COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze
|
|
||||||
COLUMNS = {
|
COLUMNS = {
|
||||||
'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'],
|
'posts' => COMMON_COLUMNS + [
|
||||||
'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
'thumbnail', 'thumbnail_base', 'title', 'url', 'tags', 'parents', 'video_ms',
|
||||||
|
'original_created_at', 'original_created_from', 'original_created_before'],
|
||||||
|
'tags' => COMMON_COLUMNS + [
|
||||||
|
'name', 'names', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
|
||||||
KEYS = [
|
KEYS = [
|
||||||
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before',
|
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
|
||||||
'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze
|
'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze
|
||||||
|
|
||||||
attr_reader :page, :limit, :mode, :axis, :columns
|
attr_reader :page, :limit, :mode, :axis
|
||||||
|
|
||||||
def initialize(scope, resource, params)
|
def initialize(scope, resource, params)
|
||||||
@scope = scope.except(:select, :order)
|
|
||||||
@resource = resource
|
|
||||||
@table = scope.klass.table_name
|
@table = scope.klass.table_name
|
||||||
|
@resource = resource
|
||||||
|
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
|
||||||
@params = params
|
@params = params
|
||||||
|
@scope = scope.except(:select, :order).joins(
|
||||||
|
"LEFT JOIN users counts_actor ON counts_actor.id = #{ @table }.created_by_user_id")
|
||||||
raise ArgumentError unless (params.keys - KEYS).empty?
|
raise ArgumentError unless (params.keys - KEYS).empty?
|
||||||
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
raise ArgumentError unless params.values.all? { _1.is_a?(String) }
|
||||||
|
|
||||||
@mode = params.fetch('mode', 'summary')
|
@mode = params.fetch('mode', 'summary')
|
||||||
@axis = params.fetch('axis', 'event_type')
|
@axis = params.fetch('axis', 'month')
|
||||||
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
|
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
|
||||||
|
|
||||||
@page = integer('page', 1)
|
@page = integer('page', 1)
|
||||||
@limit = integer('limit', 20)
|
@limit = integer('limit', 20)
|
||||||
raise ArgumentError if limit > 100 || page > 1_000_000
|
raise ArgumentError if limit > 100 || page > 1_000_000
|
||||||
|
|
||||||
@columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
|
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
|
||||||
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
|
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
|
||||||
raise ArgumentError
|
raise ArgumentError
|
||||||
end
|
end
|
||||||
|
@sort = parse_sort
|
||||||
apply_filters
|
apply_filters
|
||||||
end
|
end
|
||||||
|
|
||||||
def count = @scope.count
|
def count = @scope.count
|
||||||
|
|
||||||
def details(scope)
|
def latest_expression
|
||||||
scope.where(id: @scope.select("#{ @table }.id"))
|
<<~SQL.squish
|
||||||
.order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC"))
|
NOT EXISTS (SELECT 1 FROM #{ @table } newer
|
||||||
|
WHERE newer.#{ @id_column } = #{ @table }.#{ @id_column }
|
||||||
|
AND newer.version_no > #{ @table }.version_no)
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def details
|
||||||
|
@scope.select("#{ @table }.*", Arel.sql("(#{ latest_expression }) AS counts_latest"))
|
||||||
|
.order(Arel.sql(order_sql(detail_expressions, ["#{ @table }.id DESC"])))
|
||||||
.limit(limit).offset((page - 1) * limit)
|
.limit(limit).offset((page - 1) * limit)
|
||||||
end
|
end
|
||||||
|
|
||||||
def summary
|
def summary
|
||||||
expression = case axis
|
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
|
||||||
when 'event_type' then "#{ @table }.event_type"
|
"#{ @table }.created_by_user_id"
|
||||||
when 'user' then "#{ @table }.created_by_user_id"
|
|
||||||
when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')"
|
|
||||||
end
|
|
||||||
grouped = @scope.group(Arel.sql(expression))
|
grouped = @scope.group(Arel.sql(expression))
|
||||||
# A derived table counts groups without materialising every group in Ruby.
|
|
||||||
sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql
|
sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql
|
||||||
groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups")
|
groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups")
|
||||||
rows = grouped.order(Arel.sql("#{ expression } ASC"))
|
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
|
||||||
|
expressions = { 'group' => [group_order], 'count' => ['COUNT(*)'] }
|
||||||
|
final = ["(#{ expression }) IS NULL ASC", "#{ expression } ASC"]
|
||||||
|
rows = grouped.order(Arel.sql(order_sql(expressions, final)))
|
||||||
.limit(limit).offset((page - 1) * limit).count
|
.limit(limit).offset((page - 1) * limit).count
|
||||||
names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {}
|
names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {}
|
||||||
{ group_count: groups.to_i,
|
{ group_count: groups.to_i,
|
||||||
@@ -62,6 +74,79 @@ class CountsQuery
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def parse_sort
|
||||||
|
value = @params.fetch('sort', mode == 'details' ? 'created_at:desc' : 'group:asc')
|
||||||
|
return [] if value.empty?
|
||||||
|
|
||||||
|
values = value.split(',', -1).map { _1.split(':', -1) }
|
||||||
|
allowed = mode == 'details' ? COLUMNS.fetch(@resource) - ['thumbnail'] : ['group', 'count']
|
||||||
|
invalid = values.any? do |key, direction, *extra|
|
||||||
|
extra.any? || !allowed.include?(key) || !['asc', 'desc'].include?(direction)
|
||||||
|
end
|
||||||
|
if (values.empty? || values.length > allowed.length || invalid ||
|
||||||
|
values.map(&:first).uniq.length != values.length)
|
||||||
|
raise ArgumentError
|
||||||
|
end
|
||||||
|
values
|
||||||
|
end
|
||||||
|
|
||||||
|
# NULL always sorts last, including descending keys. The final key is unique.
|
||||||
|
def order_sql(expressions, final)
|
||||||
|
(@sort.flat_map do |key, direction|
|
||||||
|
expressions.fetch(key).flat_map do |expression|
|
||||||
|
["(#{ expression }) IS NULL ASC", "#{ expression } #{ direction.upcase }"]
|
||||||
|
end
|
||||||
|
end + final).join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
def detail_expressions
|
||||||
|
common = { 'resource_id' => ["#{ @table }.#{ @id_column }"],
|
||||||
|
'version_no' => ["#{ @table }.version_no"],
|
||||||
|
'is_latest' => ["(#{ latest_expression })"],
|
||||||
|
'user' => ['counts_actor.name', "#{ @table }.created_by_user_id"],
|
||||||
|
'created_at' => ["#{ @table }.created_at"] }
|
||||||
|
if @resource == 'posts'
|
||||||
|
fields = [
|
||||||
|
'title', 'url', 'thumbnail_base', 'video_ms',
|
||||||
|
'original_created_from', 'original_created_before']
|
||||||
|
common.merge(fields.to_h { [_1, ["#{ @table }.#{ _1 }"]] }).merge(
|
||||||
|
'original_created_at' => [
|
||||||
|
'post_versions.original_created_from', 'post_versions.original_created_before'],
|
||||||
|
'tags' => json_order('post_versions.tags_json'),
|
||||||
|
'parents' => list_order('post_versions.parent_post_ids'))
|
||||||
|
else
|
||||||
|
name = "JSON_UNQUOTE(JSON_EXTRACT(tag_versions.names, #{ locale_name_path }))"
|
||||||
|
common.merge(
|
||||||
|
'name' => ["COALESCE(#{ name }, tag_versions.name)"],
|
||||||
|
'names' => json_order('tag_versions.names'),
|
||||||
|
'category' => ['tag_versions.category'],
|
||||||
|
'aliases' => json_order(
|
||||||
|
"JSON_EXTRACT(tag_versions.names, '$.*.aliases[*]')"),
|
||||||
|
'parents' => list_order('tag_versions.parent_tag_ids'),
|
||||||
|
'deprecated' => ['tag_versions.deprecated_at'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def locale_name_path
|
||||||
|
language = Locale.find_by(code: @params.fetch('locale', 'ja'))&.language_code || 'ja'
|
||||||
|
path = "$.#{ JSON.generate(language) }.name"
|
||||||
|
@scope.klass.connection.quote(path)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Multi-valued parent cells sort by cardinality, then binary snapshot text.
|
||||||
|
def json_order(column)
|
||||||
|
[
|
||||||
|
"COALESCE(JSON_LENGTH(#{ column }), 0)",
|
||||||
|
"COALESCE(CAST(#{ column } AS CHAR), '[]') COLLATE utf8mb4_bin"]
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_order(column)
|
||||||
|
[
|
||||||
|
"CASE WHEN #{ column } = '' THEN 0 ELSE " +
|
||||||
|
"1 + LENGTH(#{ column }) - LENGTH(REPLACE(#{ column }, ' ', '')) END",
|
||||||
|
"#{ column } COLLATE utf8mb4_bin"]
|
||||||
|
end
|
||||||
|
|
||||||
def integer(key, default = nil)
|
def integer(key, default = nil)
|
||||||
value = @params[key].presence
|
value = @params[key].presence
|
||||||
return default if value.nil? && !default.nil?
|
return default if value.nil? && !default.nil?
|
||||||
@@ -83,22 +168,18 @@ class CountsQuery
|
|||||||
first = timestamp('from')
|
first = timestamp('from')
|
||||||
last = timestamp('before')
|
last = timestamp('before')
|
||||||
raise ArgumentError if first && last && first >= last
|
raise ArgumentError if first && last && first >= last
|
||||||
|
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '0'))
|
||||||
|
|
||||||
|
@scope = @scope.where(Arel.sql(latest_expression)) if @params['latest'] == '1'
|
||||||
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
|
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
|
||||||
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
|
||||||
if @params['resource_id'].present?
|
if @params['resource_id'].present?
|
||||||
id_column = @resource == 'posts' ? 'post_id' : 'tag_id'
|
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
|
||||||
@scope = @scope.where(@table => { id_column => integer('resource_id') })
|
|
||||||
end
|
end
|
||||||
if @params['user_id'].present?
|
if @params['user_id'].present?
|
||||||
user = @params['user_id'] == 'none' ? nil : integer('user_id')
|
user = @params['user_id'] == 'none' ? nil : integer('user_id')
|
||||||
@scope = @scope.where(@table => { created_by_user_id: user })
|
@scope = @scope.where(@table => { created_by_user_id: user })
|
||||||
end
|
end
|
||||||
if @params['event_type'].present?
|
|
||||||
raise ArgumentError unless EVENTS.include?(@params['event_type'])
|
|
||||||
|
|
||||||
@scope = @scope.where(@table => { event_type: @params['event_type'] })
|
|
||||||
end
|
|
||||||
return unless @params['tag'].present?
|
return unless @params['tag'].present?
|
||||||
raise ArgumentError unless @resource == 'posts'
|
raise ArgumentError unless @resource == 'posts'
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -33,9 +33,9 @@ npm run lint
|
|||||||
|
|
||||||
If either command cannot be run or fails, report the exact command and failure.
|
If either command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
Do not create, modify, or run tests unless the user explicitly asks for test
|
明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
|
||||||
work. When the user asks for tests, keep working and rerun them until they
|
fixture、mock、snapshot、テスト補助コード及び既存のテスト差分にも触れない。
|
||||||
pass or the remaining failure is clearly blocked.
|
ビルド・lint・静的確認を使い、未確認事項はそのまま報告する。
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|
||||||
@@ -134,9 +134,9 @@ pass or the remaining failure is clearly blocked.
|
|||||||
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
|
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
|
||||||
`_untranslated` 規約を使い、language metadata を維持する。
|
`_untranslated` 規約を使い、language metadata を維持する。
|
||||||
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
|
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
|
||||||
- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の
|
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
|
||||||
確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。
|
SSR / hydration の整合及び翻訳キーの非露出を検証する。
|
||||||
この規約はテストコードの追加・修正を義務付けない。
|
i18n の検証を理由にテスト関連作業を開始しない。
|
||||||
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
|
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
|
||||||
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
|
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
|
||||||
アクセシビリティ用ラベルも UI 文言として扱う。
|
アクセシビリティ用ラベルも UI 文言として扱う。
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useId, useState } from 'react'
|
||||||
|
|
||||||
|
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||||
|
import DateTimeField from '@/components/common/DateTimeField'
|
||||||
|
import FormField from '@/components/common/FormField'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { normaliseCountsParams } from '@/lib/counts'
|
||||||
|
import { fetchTagByName } from '@/lib/tags'
|
||||||
|
import { inputClass } from '@/lib/utils'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsControls = (
|
||||||
|
{ resource, applied, open, setOpen, execute }: {
|
||||||
|
resource: CountsResource
|
||||||
|
applied: URLSearchParams
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
execute: (params: URLSearchParams) => void },
|
||||||
|
) => {
|
||||||
|
const { label, message } = useUITranslation ('counts')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const id = useId ()
|
||||||
|
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
||||||
|
const [error, setError] = useState (false)
|
||||||
|
const [pending, setPending] = useState (false)
|
||||||
|
const mode = draft.get ('mode') ?? 'summary'
|
||||||
|
const update = (key: string, value: string) => {
|
||||||
|
setDraft (previous => {
|
||||||
|
const next = new URLSearchParams (previous)
|
||||||
|
if (key === 'mode') next.delete ('sort')
|
||||||
|
if (value || key === 'columns' || key === 'children') next.set (key, value)
|
||||||
|
else next.delete (key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const submit = async () => {
|
||||||
|
setError (false)
|
||||||
|
setPending (true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const next = new URLSearchParams (draft)
|
||||||
|
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
|
||||||
|
{
|
||||||
|
const value = next.get (key)?.trim () ?? ''
|
||||||
|
if (value && !/^[1-9][0-9]*$/.test (value))
|
||||||
|
{
|
||||||
|
const tag = await fetchTagByName (value, locale)
|
||||||
|
if (tag == null)
|
||||||
|
throw new Error ()
|
||||||
|
next.set (key, String (tag.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.delete ('page')
|
||||||
|
execute (normaliseCountsParams (resource, next))
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
setError (true)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
setPending (false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="shrink-0" onSubmit={event => {
|
||||||
|
event.preventDefault ()
|
||||||
|
void submit ()
|
||||||
|
}}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700">
|
||||||
|
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button>
|
||||||
|
<Button type="button" size="sm" variant="outline" aria-expanded={open}
|
||||||
|
aria-controls={id} onClick={() => setOpen (!open)}>
|
||||||
|
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
|
||||||
|
</Button>
|
||||||
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
|
aria-label={message ('mode').text} lang={message ('mode').language}
|
||||||
|
value={mode} onChange={event => update ('mode', event.target.value)}>
|
||||||
|
<option value="summary" lang={message ('summary').language}>
|
||||||
|
{message ('summary').text}</option>
|
||||||
|
<option value="details" lang={message ('details').language}>
|
||||||
|
{message ('details').text}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
||||||
|
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<fieldset className="min-w-0" data-counts-output>
|
||||||
|
<legend className="mb-2 font-semibold">{label ('output')}</legend>
|
||||||
|
{mode === 'summary' ? (
|
||||||
|
<label className="flex flex-wrap items-center gap-2">
|
||||||
|
{label ('axis')}
|
||||||
|
<select className={inputClass (false, 'w-auto py-1')}
|
||||||
|
value={draft.get ('axis') ?? 'month'}
|
||||||
|
aria-label={message ('axis').text} lang={message ('axis').language}
|
||||||
|
onChange={event => update ('axis', event.target.value)}>
|
||||||
|
{['month', 'user'].map (axis => (
|
||||||
|
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
|
||||||
|
{message (`axes.${axis}`).text}</option>))}
|
||||||
|
</select>
|
||||||
|
</label>) : (
|
||||||
|
<CountsFieldTree resource={resource} params={draft} update={update}/>)}
|
||||||
|
</fieldset>
|
||||||
|
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||||
|
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<input type="checkbox" checked={draft.get ('latest') === '1'}
|
||||||
|
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||||
|
{label ('latestOnly')}
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{['from', 'before'].map (key => (
|
||||||
|
<FormField key={key} label={label (`fields.${key}`)}>
|
||||||
|
{() => <DateTimeField value={draft.get (key) ?? ''}
|
||||||
|
aria-label={message (`fields.${key}`).text}
|
||||||
|
lang={message (`fields.${key}`).language}
|
||||||
|
onChange={value => update (key, value ?? '')}/>}
|
||||||
|
</FormField>))}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
{[['resource_id', resource === 'posts' ? 'post' : 'tag'], ['user_id', 'actor'],
|
||||||
|
...(resource === 'posts' ? [['tag', 'tag']] : []), ['limit', 'limit']]
|
||||||
|
.map (([key, field]) => (
|
||||||
|
<FormField key={key} label={label (`fields.${field}`)}>
|
||||||
|
{() => <input aria-label={message (`fields.${field}`).text}
|
||||||
|
lang={message (`fields.${field}`).language}
|
||||||
|
type={key === 'limit' ? 'number' : 'text'} min={1} max={100}
|
||||||
|
value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
|
||||||
|
onChange={event => update (key, event.target.value)}
|
||||||
|
className={inputClass (false, 'py-1')}/>}
|
||||||
|
</FormField>))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsControls
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { countChildPaths, fieldsForCounts, selectedCountChildren,
|
||||||
|
selectedCountColumns } from '@/lib/counts'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountField, CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const FieldBranch = (
|
||||||
|
{ field, path, enabled, selected, select }: {
|
||||||
|
field: CountField
|
||||||
|
path: string
|
||||||
|
enabled: boolean
|
||||||
|
selected: Set<string>
|
||||||
|
select: (path: string, checked: boolean) => void },
|
||||||
|
) => {
|
||||||
|
const { label } = useUITranslation ('counts')
|
||||||
|
const checked = selected.has (path)
|
||||||
|
const control = <label className="inline-flex items-center gap-1">
|
||||||
|
<input type="checkbox" checked={checked} disabled={!enabled}
|
||||||
|
onClick={event => event.stopPropagation ()}
|
||||||
|
onChange={event => select (path, event.target.checked)}/>
|
||||||
|
{label (field.label)}
|
||||||
|
</label>
|
||||||
|
return field.children ? (
|
||||||
|
<details className="my-1">
|
||||||
|
<summary className="cursor-pointer">{control}</summary>
|
||||||
|
<div className="ml-4 border-l pl-2 dark:border-slate-700">
|
||||||
|
{field.children.map (child => (
|
||||||
|
<FieldBranch key={child.key} field={child} path={`${path}.${child.key}`}
|
||||||
|
enabled={enabled && checked} selected={selected} select={select}/>))}
|
||||||
|
</div>
|
||||||
|
</details>) : <div className="my-1">{control}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const CountsFieldTree = (
|
||||||
|
{ resource, params, update }: {
|
||||||
|
resource: CountsResource
|
||||||
|
params: URLSearchParams
|
||||||
|
update: (key: string, value: string) => void },
|
||||||
|
) => {
|
||||||
|
const { label } = useUITranslation ('counts')
|
||||||
|
const fields = fieldsForCounts (resource)
|
||||||
|
const roots = selectedCountColumns (resource, params)
|
||||||
|
const selected = selectedCountChildren (params)
|
||||||
|
for (const root of roots) selected.add (root)
|
||||||
|
const select = (path: string, checked: boolean) => {
|
||||||
|
if (!path.includes ('.'))
|
||||||
|
{
|
||||||
|
const next = new Set<string> (roots)
|
||||||
|
if (checked) next.add (path)
|
||||||
|
else next.delete (path)
|
||||||
|
update ('columns', fields.filter (field => next.has (field.key))
|
||||||
|
.map (field => field.key).join (','))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (checked) selected.add (path)
|
||||||
|
else selected.delete (path)
|
||||||
|
update ('children', countChildPaths (fields).filter (
|
||||||
|
key => key.includes ('.') && selected.has (key)).join (','))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fields.map (field => (
|
||||||
|
<FieldBranch key={field.key} field={field} path={field.key}
|
||||||
|
enabled selected={selected} select={select}/>))}
|
||||||
|
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
|
||||||
|
</>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsFieldTree
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
|
import { changeCountSort, readCountSort, selectedCountChildren,
|
||||||
|
writeCountSort } from '@/lib/counts'
|
||||||
|
import { countsLogicalName, countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountField, CountRecord, CountsResource } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsNestedTable = (
|
||||||
|
{ rows, fields, path, resource, title, params, update }: {
|
||||||
|
rows: CountRecord[]
|
||||||
|
fields: CountField[]
|
||||||
|
path: string
|
||||||
|
resource: CountsResource
|
||||||
|
title: { text: string; language: string }
|
||||||
|
params: URLSearchParams
|
||||||
|
update: (key: string, value: string) => void },
|
||||||
|
) => {
|
||||||
|
const { message } = useUITranslation ('counts')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const selected = selectedCountChildren (params)
|
||||||
|
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||||
|
const allSorts = readCountSort (params.get ('child_sort'))
|
||||||
|
const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
|
||||||
|
.map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
|
||||||
|
const ordered = countsNestedOrder (rows, path, params, locale)
|
||||||
|
const scalar = (row: CountRecord, field: string) => {
|
||||||
|
const value = row[field]
|
||||||
|
if (value == null) return null
|
||||||
|
if (field === 'id')
|
||||||
|
{
|
||||||
|
if (row.kind === 'external') return String (value)
|
||||||
|
const target = path === 'parents' ? resource : 'tags'
|
||||||
|
return <PrefetchLink to={`/${target}/${encodeURIComponent (String (value))}`}>
|
||||||
|
{String (value)}</PrefetchLink>
|
||||||
|
}
|
||||||
|
const display = countsLogicalName (row, field, locale)
|
||||||
|
if (typeof value === 'boolean')
|
||||||
|
return <span aria-label={display.text} lang={display.language}>{value ? '✓' : '✗'}</span>
|
||||||
|
if (display.language != null)
|
||||||
|
return <LocalisedText language={display.language}>{display.text}</LocalisedText>
|
||||||
|
if (field === 'name' && typeof row.languageCode === 'string')
|
||||||
|
return <LocalisedText language={row.languageCode}>{String (value)}</LocalisedText>
|
||||||
|
return display.text
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<details className="w-full min-w-0 max-w-full" open>
|
||||||
|
<summary className="cursor-pointer text-xs" title={message ('nested.toggle').text}>
|
||||||
|
<LocalisedText language={title.language}>{title.text}</LocalisedText> ({rows.length})
|
||||||
|
</summary>
|
||||||
|
{columns.length > 0 && rows.length > 0 && (
|
||||||
|
<div className="relative isolate mt-1 max-h-72 w-full min-w-0 overflow-auto">
|
||||||
|
<CountsSelectableTable family={path} columns={columns.map (field => field.key)}
|
||||||
|
identity={`${path}:${params}`} name={title}
|
||||||
|
className="border text-xs dark:border-slate-700">
|
||||||
|
<thead className="sticky top-0 z-10 bg-background text-foreground
|
||||||
|
dark:bg-background dark:text-foreground"><tr>
|
||||||
|
{columns.map (field => (
|
||||||
|
<CountsSortHeader key={field.key} field={field.key}
|
||||||
|
label={message (field.label)}
|
||||||
|
sorts={sorts} structured={field.children != null}
|
||||||
|
change={(key, additive) => {
|
||||||
|
const next = changeCountSort (sorts, key, additive)
|
||||||
|
.map (sort => ({ ...sort, key: `${path}/${sort.key}` }))
|
||||||
|
update ('child_sort', writeCountSort ([
|
||||||
|
...allSorts.filter (sort => !sort.key.startsWith (`${path}/`)),
|
||||||
|
...next]))
|
||||||
|
}}/>))}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>{ordered.map ((row, index) => (
|
||||||
|
<tr key={index} className="border-t dark:border-slate-700">
|
||||||
|
{columns.map (field => (
|
||||||
|
<td key={field.key}
|
||||||
|
className="overflow-hidden break-words border-t px-2 py-1
|
||||||
|
align-top dark:border-slate-700"
|
||||||
|
data-counts-copy={
|
||||||
|
field.children
|
||||||
|
? countsNestedText (
|
||||||
|
row[field.key] as CountRecord[] ?? [], field.children,
|
||||||
|
`${path}.${field.key}`, params, locale)
|
||||||
|
: countsLogicalName (row, field.key, locale).text}>
|
||||||
|
{field.children
|
||||||
|
? <CountsNestedTable rows={row[field.key] as CountRecord[] ?? []}
|
||||||
|
fields={field.children} path={`${path}.${field.key}`}
|
||||||
|
resource={resource}
|
||||||
|
title={message (field.label)} params={params} update={update}/>
|
||||||
|
: scalar (row, field.key)}
|
||||||
|
</td>))}
|
||||||
|
</tr>))}</tbody>
|
||||||
|
</CountsSelectableTable>
|
||||||
|
</div>)}
|
||||||
|
</details>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsNestedTable
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { CountsTableScope, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH,
|
||||||
|
useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsResizeHandle = ({ field, label }: {
|
||||||
|
field: string; label: { text: string; language: string } }) => {
|
||||||
|
const family = useContext (CountsTableScope)
|
||||||
|
const layout = useCountsTableLayout ()
|
||||||
|
const { message } = useUITranslation ('counts')
|
||||||
|
const [active, setActive] = useState (false)
|
||||||
|
const press = useRef<{ x: number; y: number; width: number; armed: boolean
|
||||||
|
timer?: ReturnType<typeof setTimeout> } | null> (null)
|
||||||
|
const saved = useRef<{ cursor: string; userSelect: string } | null> (null)
|
||||||
|
const restore = () => {
|
||||||
|
if (saved.current == null) return
|
||||||
|
document.body.style.cursor = saved.current.cursor
|
||||||
|
document.body.style.userSelect = saved.current.userSelect
|
||||||
|
saved.current = null
|
||||||
|
}
|
||||||
|
const finish = () => {
|
||||||
|
if (press.current?.timer != null) clearTimeout (press.current.timer)
|
||||||
|
press.current = null
|
||||||
|
setActive (false)
|
||||||
|
restore ()
|
||||||
|
}
|
||||||
|
const arm = () => {
|
||||||
|
if (press.current == null) return
|
||||||
|
press.current.armed = true
|
||||||
|
saved.current = { cursor: document.body.style.cursor,
|
||||||
|
userSelect: document.body.style.userSelect }
|
||||||
|
document.body.style.cursor = 'col-resize'
|
||||||
|
document.body.style.userSelect = 'none'
|
||||||
|
setActive (true)
|
||||||
|
}
|
||||||
|
useEffect (() => () => {
|
||||||
|
if (press.current?.timer != null) clearTimeout (press.current.timer)
|
||||||
|
if (saved.current != null)
|
||||||
|
{
|
||||||
|
document.body.style.cursor = saved.current.cursor
|
||||||
|
document.body.style.userSelect = saved.current.userSelect
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
const name = message ('resize.column', { column: label.text })
|
||||||
|
return (
|
||||||
|
<span role="separator" tabIndex={0} aria-orientation="vertical"
|
||||||
|
aria-label={name.text} lang={name.language}
|
||||||
|
aria-valuenow={layout.width (family, field)} aria-valuemin={MIN_COUNT_WIDTH}
|
||||||
|
aria-valuemax={MAX_COUNT_WIDTH} title={name.text}
|
||||||
|
data-counts-resize
|
||||||
|
className={cn ('absolute inset-y-0 right-0 z-30 w-3 cursor-col-resize touch-none',
|
||||||
|
'select-none outline-offset-[-2px] focus-visible:outline',
|
||||||
|
'before:absolute before:inset-y-1 before:left-1/2 before:border-l',
|
||||||
|
'before:border-slate-300 dark:before:border-slate-600',
|
||||||
|
'hover:before:border-blue-500 dark:hover:before:border-blue-400',
|
||||||
|
active && 'before:border-blue-500 dark:before:border-blue-400')}
|
||||||
|
onPointerDown={event => {
|
||||||
|
event.stopPropagation ()
|
||||||
|
if (!event.isPrimary || event.button !== 0) return
|
||||||
|
event.preventDefault ()
|
||||||
|
finish ()
|
||||||
|
event.currentTarget.setPointerCapture (event.pointerId)
|
||||||
|
press.current = { x: event.clientX, y: event.clientY,
|
||||||
|
width: layout.width (family, field), armed: false }
|
||||||
|
if (event.pointerType === 'mouse') arm ()
|
||||||
|
else press.current.timer = setTimeout (arm, 400)
|
||||||
|
}}
|
||||||
|
onPointerMove={event => {
|
||||||
|
event.stopPropagation ()
|
||||||
|
const state = press.current
|
||||||
|
if (state == null) return
|
||||||
|
if (state.armed)
|
||||||
|
layout.resize (family, field, state.width + event.clientX - state.x)
|
||||||
|
else if (Math.hypot (event.clientX - state.x, event.clientY - state.y) > 8)
|
||||||
|
finish ()
|
||||||
|
}}
|
||||||
|
onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={finish}
|
||||||
|
onClick={event => { event.preventDefault (); event.stopPropagation () }}
|
||||||
|
onContextMenu={event => event.preventDefault ()}
|
||||||
|
onKeyDown={event => {
|
||||||
|
event.stopPropagation ()
|
||||||
|
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return
|
||||||
|
event.preventDefault ()
|
||||||
|
layout.resize (family, field, layout.width (family, field)
|
||||||
|
+ (event.key === 'ArrowLeft' ? -10 : 10))
|
||||||
|
}}/>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsResizeHandle
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import LocalisedText from '@/components/LocalisedText'
|
||||||
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
|
import CountsNestedTable from '@/components/counts/CountsNestedTable'
|
||||||
|
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||||
|
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||||
|
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||||
|
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||||
|
import { changeCountSort, countColumns, fieldsForCounts,
|
||||||
|
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
|
||||||
|
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
|
||||||
|
import { useLocale } from '@/lib/useLocale'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountColumn, CountRecord, CountsResource,
|
||||||
|
CountsResult, CountsVersion } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsResults = (
|
||||||
|
{ resource, data, params, pending, update }: {
|
||||||
|
resource: CountsResource
|
||||||
|
data: CountsResult
|
||||||
|
params: URLSearchParams
|
||||||
|
pending: boolean
|
||||||
|
update: (key: string, value: string) => void },
|
||||||
|
) => {
|
||||||
|
const { label, message } = useUITranslation ('counts')
|
||||||
|
const locale = useLocale ()
|
||||||
|
const summary = (params.get ('mode') ?? 'summary') === 'summary'
|
||||||
|
const axis = params.get ('axis') ?? 'month'
|
||||||
|
const columns = selectedCountColumns (resource, params)
|
||||||
|
const fields = fieldsForCounts (resource)
|
||||||
|
const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc'))
|
||||||
|
const sort = (key: string, additive: boolean) =>
|
||||||
|
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||||
|
const drillDown = (key: string | number | null): string => {
|
||||||
|
const next = new URLSearchParams (params)
|
||||||
|
next.set ('mode', 'details')
|
||||||
|
next.delete ('page')
|
||||||
|
next.delete ('sort')
|
||||||
|
if (axis === 'month')
|
||||||
|
{
|
||||||
|
const first = `${key}-01T00:00Z`
|
||||||
|
const date = new Date (first)
|
||||||
|
date.setUTCMonth (date.getUTCMonth () + 1)
|
||||||
|
const last = date.toISOString ().slice (0, 16) + 'Z'
|
||||||
|
const from = next.get ('from')
|
||||||
|
const before = next.get ('before')
|
||||||
|
next.set ('from',
|
||||||
|
(from != null && Date.parse (from) > Date.parse (first)) ? from : first)
|
||||||
|
next.set ('before',
|
||||||
|
(before != null && Date.parse (before) < Date.parse (last)) ? before : last)
|
||||||
|
}
|
||||||
|
else next.set ('user_id', String (key ?? 'none'))
|
||||||
|
return `?${next}`
|
||||||
|
}
|
||||||
|
const cell = (row: CountsVersion, column: CountColumn) => {
|
||||||
|
const field = fields.find (field => field.key === column)!
|
||||||
|
const value = countsCellText (row, column, locale)
|
||||||
|
if (field.children != null)
|
||||||
|
{
|
||||||
|
return <CountsNestedTable rows={row[column as keyof CountsVersion] as CountRecord[] ?? []}
|
||||||
|
fields={field.children} path={column} resource={resource}
|
||||||
|
title={message (field.label)} params={params} update={update}/>
|
||||||
|
}
|
||||||
|
switch (column)
|
||||||
|
{
|
||||||
|
case 'resource_id':
|
||||||
|
return <PrefetchLink to={`/${resource}/${encodeURIComponent (row.resourceId)}`}>
|
||||||
|
{row.resourceId}</PrefetchLink>
|
||||||
|
case 'thumbnail':
|
||||||
|
{
|
||||||
|
const url = countsImageURL (row.thumbnailBase)
|
||||||
|
return url == null ? null : <PostThumbnailPreview url={url} className="h-10 w-16"
|
||||||
|
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
|
||||||
|
}
|
||||||
|
case 'url':
|
||||||
|
case 'thumbnail_base':
|
||||||
|
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
|
||||||
|
{value}</a>
|
||||||
|
case 'is_latest':
|
||||||
|
return <span aria-label={message (`boolean.${row.isLatest}`).text}
|
||||||
|
lang={message (`boolean.${row.isLatest}`).language}>{row.isLatest ? '✓' : '✗'}</span>
|
||||||
|
case 'user':
|
||||||
|
return row.createdByUser?.name ? value : (
|
||||||
|
<LocalisedText language={message (
|
||||||
|
row.createdByUser == null ? 'history:bot' : 'history:anonymous').language}>
|
||||||
|
{value}</LocalisedText>)
|
||||||
|
case 'category':
|
||||||
|
return <LocalisedText language={message (`common:categories.${row.category}`).language}>
|
||||||
|
{value}</LocalisedText>
|
||||||
|
default: return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||||
|
{!summary && columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||||
|
<CountsSelectableTable family={summary ? 'summary' : resource}
|
||||||
|
columns={summary ? ['group', 'count'] : columns}
|
||||||
|
identity={`${resource}:${params}:${(data.versions ?? []).map (row => row.id)}`}
|
||||||
|
className="text-left text-sm">
|
||||||
|
<thead className="sticky top-0 z-20 bg-background text-foreground
|
||||||
|
dark:bg-background dark:text-foreground"><tr>
|
||||||
|
{summary ? <>
|
||||||
|
<CountsSortHeader field="group" label={message (`axes.${axis}`)}
|
||||||
|
sorts={sorts} change={sort}/>
|
||||||
|
<CountsSortHeader field="count" label={message ('metric')}
|
||||||
|
sorts={sorts} change={sort}/>
|
||||||
|
</> : columns.map (column => column === 'thumbnail'
|
||||||
|
? <th key={column} className="relative px-2 py-1">
|
||||||
|
<span className="sr-only">{label (countColumns[column])}</span>
|
||||||
|
<CountsResizeHandle field={column} label={message (countColumns[column])}/>
|
||||||
|
</th>
|
||||||
|
: <CountsSortHeader key={column} field={column}
|
||||||
|
label={message (fields.find (field => field.key === column)!.label)}
|
||||||
|
sorts={sorts} change={sort} structured={fields.find (
|
||||||
|
field => field.key === column)?.children != null}/>)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{summary ? (data.groups ?? []).map (group => (
|
||||||
|
<tr key={String (group.key)} className="border-b dark:border-slate-700">
|
||||||
|
<td className="break-words border-b px-2 py-1 dark:border-slate-700"
|
||||||
|
data-counts-copy={group.key == null ? message (
|
||||||
|
'history:bot').text : group.name ?? String (group.key)}>
|
||||||
|
<PrefetchLink to={drillDown (group.key)}>
|
||||||
|
{group.key == null
|
||||||
|
? label ('history:bot') : group.name ?? String (group.key)}
|
||||||
|
</PrefetchLink></td>
|
||||||
|
<td className="border-b px-2 py-1 dark:border-slate-700"
|
||||||
|
data-counts-copy={String (group.count)}>{group.count}</td>
|
||||||
|
</tr>)) : (data.versions ?? []).map (row => (
|
||||||
|
<tr key={row.id} className="border-b dark:border-slate-700">
|
||||||
|
{columns.map (column => (
|
||||||
|
<td key={column}
|
||||||
|
className="overflow-hidden break-words border-b px-2 py-1
|
||||||
|
align-top dark:border-slate-700"
|
||||||
|
data-counts-copy={countsCellCopy (
|
||||||
|
row,
|
||||||
|
fields.find (field => field.key === column)!, params, locale)}>
|
||||||
|
{cell (row, column)}</td>))}
|
||||||
|
</tr>))}
|
||||||
|
</tbody>
|
||||||
|
</CountsSelectableTable>)}
|
||||||
|
{(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0
|
||||||
|
&& <p className="p-2">{label ('empty')}</p>}
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsResults
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { ClipboardEvent, ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Point = { row: number; column: number }
|
||||||
|
type Selection = { identity: string; anchor: Point; end: Point }
|
||||||
|
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
||||||
|
|
||||||
|
|
||||||
|
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||||
|
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||||
|
|
||||||
|
|
||||||
|
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||||
|
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
||||||
|
|
||||||
|
|
||||||
|
const CountsSelectableTable = (
|
||||||
|
{ family, columns, identity, children, className, name }: {
|
||||||
|
family: string
|
||||||
|
columns: string[]
|
||||||
|
identity: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
name?: { text: string; language: string } },
|
||||||
|
) => {
|
||||||
|
const id = useId ()
|
||||||
|
const root = useRef<HTMLTableElement> (null)
|
||||||
|
const dragging = useRef (false)
|
||||||
|
const touchArmed = useRef (false)
|
||||||
|
const touchPress = useRef<{ x: number; y: number
|
||||||
|
timer: ReturnType<typeof setTimeout> } | null> (null)
|
||||||
|
const [stored, setStored] = useState<Selection | null> (null)
|
||||||
|
const selection = stored?.identity === identity ? stored : null
|
||||||
|
const layout = useCountsTableLayout ()
|
||||||
|
const { label, message } = useUITranslation ('counts')
|
||||||
|
const bounds = useMemo (() => selection == null ? null : {
|
||||||
|
top: Math.min (selection.anchor.row, selection.end.row),
|
||||||
|
bottom: Math.max (selection.anchor.row, selection.end.row),
|
||||||
|
left: Math.min (selection.anchor.column, selection.end.column),
|
||||||
|
right: Math.max (selection.anchor.column, selection.end.column) }, [selection])
|
||||||
|
const finish = () => {
|
||||||
|
dragging.current = false
|
||||||
|
touchArmed.current = false
|
||||||
|
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
|
||||||
|
touchPress.current = null
|
||||||
|
}
|
||||||
|
useEffect (() => {
|
||||||
|
const table = root.current
|
||||||
|
const preventPan = (event: TouchEvent) => {
|
||||||
|
if (touchArmed.current) event.preventDefault ()
|
||||||
|
}
|
||||||
|
table?.addEventListener ('touchmove', preventPan, { passive: false })
|
||||||
|
return () => {
|
||||||
|
table?.removeEventListener ('touchmove', preventPan)
|
||||||
|
if (touchPress.current != null) clearTimeout (touchPress.current.timer)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
useLayoutEffect (() => {
|
||||||
|
const rows = root.current?.tBodies[0]?.rows
|
||||||
|
if (rows == null) return
|
||||||
|
for (const [row, tr] of Array.from (rows).entries ())
|
||||||
|
{
|
||||||
|
for (const [column, cell] of Array.from (tr.cells).entries ())
|
||||||
|
{
|
||||||
|
const selected = bounds != null && row >= bounds.top && row <= bounds.bottom
|
||||||
|
&& column >= bounds.left && column <= bounds.right
|
||||||
|
cell.id = `${id}-${row}-${column}`
|
||||||
|
cell.setAttribute ('role', 'gridcell')
|
||||||
|
cell.setAttribute ('aria-selected', String (selected))
|
||||||
|
if (selected) cell.dataset.countsSelected = ''
|
||||||
|
else delete cell.dataset.countsSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [bounds, children, id])
|
||||||
|
const pointFor = (element: Element | null): Point | null => {
|
||||||
|
const table = root.current
|
||||||
|
if (table == null || element == null || !table.contains (element)) return null
|
||||||
|
let cell = element.closest ('td')
|
||||||
|
while (cell != null && cell.closest ('table') !== table)
|
||||||
|
cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
|
||||||
|
if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
|
||||||
|
return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
|
||||||
|
column: (cell as HTMLTableCellElement).cellIndex }
|
||||||
|
}
|
||||||
|
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
||||||
|
if (bounds == null || root.current == null || document.activeElement !== root.current) return
|
||||||
|
event.preventDefault ()
|
||||||
|
event.stopPropagation ()
|
||||||
|
const rows: string[][] = []
|
||||||
|
for (let row = bounds.top; row <= bounds.bottom; ++row)
|
||||||
|
{
|
||||||
|
const values: string[] = []
|
||||||
|
for (let column = bounds.left; column <= bounds.right; ++column)
|
||||||
|
{
|
||||||
|
const cell = cellAt (root.current, { row, column })
|
||||||
|
values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '')
|
||||||
|
}
|
||||||
|
rows.push (values)
|
||||||
|
}
|
||||||
|
const text = rows.map (row => row.map (value => {
|
||||||
|
const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value
|
||||||
|
return `"${safe.replaceAll ('"', '""')}"`
|
||||||
|
}).join ('\t')).join ('\r\n')
|
||||||
|
const html = '<table>' + rows.map (row => '<tr>' + row.map (value =>
|
||||||
|
`<td style="white-space:pre-wrap">${escapeHTML (
|
||||||
|
/^\s*[=+@-]/.test (value) ? `'${value}` : value)}</td>`).join ('')
|
||||||
|
+ '</tr>').join ('') + '</table>'
|
||||||
|
event.clipboardData.setData ('text/plain', text)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
event.clipboardData.setData ('text/html', html)
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Plain TSV remains available when the browser disallows HTML clipboard data.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
|
||||||
|
return (
|
||||||
|
<CountsTableScope.Provider value={family}>
|
||||||
|
<table ref={root} role="grid" tabIndex={0} aria-multiselectable="true"
|
||||||
|
aria-label={name?.text ?? message ('selection.table').text}
|
||||||
|
aria-describedby={`${id}-hint`}
|
||||||
|
aria-activedescendant={selection == null ? undefined
|
||||||
|
: `${id}-${selection.end.row}-${selection.end.column}`}
|
||||||
|
style={{ tableLayout: 'fixed', width: totalWidth }}
|
||||||
|
className={cn ('select-none border-separate border-spacing-0 outline-none',
|
||||||
|
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-blue-500',
|
||||||
|
'dark:focus-visible:outline-blue-400',
|
||||||
|
'[&>tbody>tr>td[data-counts-selected]]:bg-blue-100',
|
||||||
|
'[&>tbody>tr>td[data-counts-selected]]:text-slate-900',
|
||||||
|
'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900',
|
||||||
|
'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100',
|
||||||
|
'[&>tbody>tr>td[data-counts-selected]]:outline',
|
||||||
|
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
||||||
|
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
||||||
|
onCopy={copy}
|
||||||
|
onPointerDown={event => {
|
||||||
|
const element = event.target as Element
|
||||||
|
if (element.closest ('table') !== event.currentTarget
|
||||||
|
|| element.closest (interactive) != null || event.button !== 0) return
|
||||||
|
const point = pointFor (element)
|
||||||
|
if (point == null) return
|
||||||
|
event.stopPropagation ()
|
||||||
|
event.currentTarget.focus ({ preventScroll: true })
|
||||||
|
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||||
|
? selection.anchor : point, end: point })
|
||||||
|
finish ()
|
||||||
|
if (event.pointerType === 'mouse')
|
||||||
|
{
|
||||||
|
event.preventDefault ()
|
||||||
|
dragging.current = true
|
||||||
|
event.currentTarget.setPointerCapture (event.pointerId)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
const table = event.currentTarget
|
||||||
|
const pointerId = event.pointerId
|
||||||
|
touchPress.current = { x: event.clientX, y: event.clientY,
|
||||||
|
timer: setTimeout (() => {
|
||||||
|
dragging.current = true
|
||||||
|
touchArmed.current = true
|
||||||
|
table.setPointerCapture (pointerId)
|
||||||
|
}, 450) }
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPointerMove={event => {
|
||||||
|
if (touchPress.current != null && !touchArmed.current && Math.hypot (
|
||||||
|
event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
|
||||||
|
finish ()
|
||||||
|
if (!dragging.current || event.buttons === 0) return
|
||||||
|
const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
|
||||||
|
if (point == null) return
|
||||||
|
event.stopPropagation ()
|
||||||
|
setStored (previous => previous?.identity !== identity ? previous : {
|
||||||
|
...previous, end: point })
|
||||||
|
}}
|
||||||
|
onPointerUp={finish} onPointerCancel={finish}
|
||||||
|
onLostPointerCapture={event => {
|
||||||
|
if (event.target === event.currentTarget) finish ()
|
||||||
|
}}
|
||||||
|
onContextMenu={event => {
|
||||||
|
if (touchPress.current != null || touchArmed.current) event.preventDefault ()
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.target !== event.currentTarget) return
|
||||||
|
if (event.key === 'Escape')
|
||||||
|
{
|
||||||
|
setStored (null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||||
|
const rows = event.currentTarget.tBodies[0]?.rows
|
||||||
|
if (rows == null || rows.length === 0) return
|
||||||
|
event.preventDefault ()
|
||||||
|
const previous = selection?.end ?? { row: 0, column: 0 }
|
||||||
|
const key = selection == null ? '' : event.key
|
||||||
|
const point = {
|
||||||
|
row: Math.max (0, Math.min (rows.length - 1, previous.row
|
||||||
|
+ (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
|
||||||
|
column: Math.max (0, Math.min (columns.length - 1, previous.column
|
||||||
|
+ (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
|
||||||
|
setStored ({ identity, anchor: event.shiftKey && selection != null
|
||||||
|
? selection.anchor : point, end: point })
|
||||||
|
cellAt (event.currentTarget, point)?.scrollIntoView ({
|
||||||
|
block: 'nearest', inline: 'nearest' })
|
||||||
|
}}>
|
||||||
|
<caption id={`${id}-hint`} className="sr-only">{label ('selection.hint')}</caption>
|
||||||
|
<colgroup>{columns.map (key => (
|
||||||
|
<col key={key} style={{ width: layout.width (family, key) }}/>))}</colgroup>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</CountsTableScope.Provider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsSelectableTable
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
|
import type { CountSort } from '@/lib/counts'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsSortHeader = (
|
||||||
|
{ field, label, sorts, change, structured = false }: {
|
||||||
|
field: string
|
||||||
|
label: { text: string; language: string }
|
||||||
|
sorts: CountSort[]
|
||||||
|
change: (key: string, additive: boolean) => void
|
||||||
|
structured?: boolean },
|
||||||
|
) => {
|
||||||
|
const { message } = useUITranslation ('counts')
|
||||||
|
const press = useRef<{ x: number; y: number
|
||||||
|
timer: ReturnType<typeof setTimeout> } | null> (null)
|
||||||
|
const suppressClick = useRef (false)
|
||||||
|
const cancel = () => {
|
||||||
|
if (press.current != null) clearTimeout (press.current.timer)
|
||||||
|
press.current = null
|
||||||
|
}
|
||||||
|
useEffect (() => () => {
|
||||||
|
if (press.current != null) clearTimeout (press.current.timer)
|
||||||
|
}, [])
|
||||||
|
const priority = sorts.findIndex (sort => sort.key === field)
|
||||||
|
const direction = sorts[priority]?.direction
|
||||||
|
const state = priority < 0 ? 'none' : (direction === 'asc' ? 'ascending' : 'descending')
|
||||||
|
const title = message (structured ? 'sort.structured' : 'sort.action')
|
||||||
|
const aria = message (priority < 0 ? 'sort.unsortedLabel'
|
||||||
|
: (sorts.length === 1 ? 'sort.singleLabel' : 'sort.label'), {
|
||||||
|
column: label.text, priority: priority + 1,
|
||||||
|
direction: message (`sort.${state}`).text })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<th className="relative overflow-hidden whitespace-nowrap px-1 py-1"
|
||||||
|
aria-sort={(sorts.length > 1 && priority >= 0) ? 'other' : state}>
|
||||||
|
<Button type="button" variant="ghost" size="sm"
|
||||||
|
className="h-auto w-full max-w-full justify-start select-none px-1 py-0 pr-3"
|
||||||
|
lang={aria.language} aria-label={aria.text} title={title.text}
|
||||||
|
onPointerDown={event => {
|
||||||
|
if (!event.isPrimary)
|
||||||
|
{
|
||||||
|
cancel ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
suppressClick.current = false
|
||||||
|
if (event.pointerType !== 'touch' && event.pointerType !== 'pen')
|
||||||
|
return
|
||||||
|
cancel ()
|
||||||
|
const x = event.clientX
|
||||||
|
const y = event.clientY
|
||||||
|
press.current = { x, y, timer: setTimeout (() => {
|
||||||
|
press.current = null
|
||||||
|
suppressClick.current = true
|
||||||
|
change (field, true)
|
||||||
|
}, 500) }
|
||||||
|
}}
|
||||||
|
onPointerMove={event => {
|
||||||
|
if (press.current != null && Math.hypot (
|
||||||
|
event.clientX - press.current.x, event.clientY - press.current.y) > 8)
|
||||||
|
cancel ()
|
||||||
|
}}
|
||||||
|
onPointerUp={cancel} onPointerCancel={cancel} onPointerLeave={cancel}
|
||||||
|
onContextMenu={event => {
|
||||||
|
if (press.current != null || suppressClick.current)
|
||||||
|
event.preventDefault ()
|
||||||
|
}}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||||
|
event.preventDefault ()
|
||||||
|
suppressClick.current = false
|
||||||
|
change (field, event.ctrlKey || event.metaKey)
|
||||||
|
}}
|
||||||
|
onClick={event => {
|
||||||
|
if (suppressClick.current)
|
||||||
|
{
|
||||||
|
suppressClick.current = false
|
||||||
|
event.preventDefault ()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change (field, event.ctrlKey || event.metaKey)
|
||||||
|
}}>
|
||||||
|
<span className="truncate" lang={label.language}>{label.text}</span>
|
||||||
|
{priority >= 0 && <span aria-hidden>
|
||||||
|
{direction === 'asc' ? '↑' : '↓'}{sorts.length > 1 && priority + 1}</span>}
|
||||||
|
</Button>
|
||||||
|
<CountsResizeHandle field={field} label={label}/>
|
||||||
|
</th>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsSortHeader
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { CountsLayoutContext, defaultCountWidths, MAX_COUNT_WIDTH, MIN_COUNT_WIDTH }
|
||||||
|
from '@/lib/countsTableLayout'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
const CountsTableLayout = ({ children }: { children: ReactNode }) => {
|
||||||
|
const [widths, setWidths] = useState<Record<string, number>> ({ })
|
||||||
|
const width = (family: string, key: string) =>
|
||||||
|
widths[`${family}/${key}`] ?? defaultCountWidths[key] ?? 160
|
||||||
|
const resize = (family: string, key: string, value: number) => {
|
||||||
|
const next = Math.round (Math.max (MIN_COUNT_WIDTH, Math.min (MAX_COUNT_WIDTH, value)))
|
||||||
|
setWidths (previous => ({ ...previous, [`${family}/${key}`]: next }))
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<CountsLayoutContext.Provider value={{ width, resize }}>
|
||||||
|
{children}
|
||||||
|
</CountsLayoutContext.Provider>)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default CountsTableLayout
|
||||||
+198
-22
@@ -1,34 +1,160 @@
|
|||||||
import { apiGet } from '@/lib/api'
|
import { apiGet } from '@/lib/api'
|
||||||
|
|
||||||
import type { LocaleCode } from '@/lib/locales'
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
import type { PostVersion, TagVersion } from '@/types'
|
import type { TagNameSnapshot } from '@/types'
|
||||||
|
|
||||||
export type CountsResource = 'posts' | 'tags'
|
export type CountsResource = 'posts' | 'tags'
|
||||||
|
export type CountsName = TagNameSnapshot & {
|
||||||
|
languageCode: string; languageName?: string | null; scriptName?: string | null }
|
||||||
|
export type CountsTag = {
|
||||||
|
id: number
|
||||||
|
kind: 'internal' | 'external'
|
||||||
|
tagId: number | null
|
||||||
|
externalTagId: number | null
|
||||||
|
versionNo: number | null
|
||||||
|
name: string | null
|
||||||
|
category: string | null
|
||||||
|
sections: { beginMs: number; endMs: number | null }[]
|
||||||
|
names: CountsName[] }
|
||||||
|
export type CountsVersion = {
|
||||||
|
id: number
|
||||||
|
resourceId: number
|
||||||
|
versionNo: number
|
||||||
|
isLatest: boolean
|
||||||
|
createdAt: string
|
||||||
|
createdByUser: { id: number; name: string | null } | null
|
||||||
|
title?: string | null
|
||||||
|
url?: string
|
||||||
|
thumbnailBase?: string | null
|
||||||
|
tags?: CountsTag[]
|
||||||
|
parents: { id: number }[]
|
||||||
|
videoMs?: number | null
|
||||||
|
originalCreatedFrom?: string | null
|
||||||
|
originalCreatedBefore?: string | null
|
||||||
|
name?: string | null
|
||||||
|
names?: CountsName[]
|
||||||
|
category?: string
|
||||||
|
aliases?: { languageCode: string; name: string; scriptCode: string }[]
|
||||||
|
deprecated?: string | null }
|
||||||
export type CountsResult = {
|
export type CountsResult = {
|
||||||
count: number
|
count: number
|
||||||
page: number
|
page: number
|
||||||
limit: number
|
limit: number
|
||||||
groupCount?: number
|
groupCount?: number
|
||||||
groups?: { key: string | number | null; name: string | null; count: number }[]
|
groups?: { key: string | number | null; name: string | null; count: number }[]
|
||||||
versions?: (PostVersion | TagVersion)[] }
|
versions?: CountsVersion[] }
|
||||||
|
export type CountSort = { key: string; direction: 'asc' | 'desc' }
|
||||||
|
export type CountField = { key: string; label: string; children?: CountField[] }
|
||||||
|
export type CountRecord = Record<string, unknown>
|
||||||
|
|
||||||
export const countColumns = {
|
export const countColumns = {
|
||||||
created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId',
|
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
||||||
version_no: 'history:version', user: 'history:actor',
|
version_no: 'history:version', is_latest: 'counts:columns.latest',
|
||||||
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
||||||
tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags',
|
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
||||||
removed_tags: 'counts:columns.removedTags', name: 'history:tagName',
|
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||||
category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents',
|
original_created_at: 'shared:labels.originalCreatedAt',
|
||||||
deprecated: 'counts:columns.deprecated' }
|
original_created_from: 'counts:columns.originalFrom',
|
||||||
|
original_created_before: 'counts:columns.originalBefore',
|
||||||
|
name: 'history:tagName', names: 'counts:columns.names',
|
||||||
|
category: 'common:categoryLabel', aliases: 'tags:aliases',
|
||||||
|
deprecated: 'counts:columns.deprecated', user: 'history:actor',
|
||||||
|
created_at: 'counts:columns.changedAt' }
|
||||||
export type CountColumn = keyof typeof countColumns
|
export type CountColumn = keyof typeof countColumns
|
||||||
|
|
||||||
|
const aliasFields: CountField[] = [
|
||||||
|
{ key: 'name', label: 'history:name' },
|
||||||
|
{ key: 'scriptCode', label: 'history:nameChanges.script' }]
|
||||||
|
const nameFields: CountField[] = [
|
||||||
|
{ key: 'languageCode', label: 'counts:columns.language' },
|
||||||
|
{ key: 'scriptCode', label: 'history:nameChanges.script' },
|
||||||
|
{ key: 'name', label: 'history:name' },
|
||||||
|
{ key: 'aliases', label: 'tags:aliases', children: aliasFields },
|
||||||
|
{ key: 'autoGenerated', label: 'history:nameChanges.automatic' }]
|
||||||
|
const nested: Partial<Record<CountColumn, CountField[]>> = {
|
||||||
|
tags: [
|
||||||
|
{ key: 'id', label: 'counts:columns.resourceId' },
|
||||||
|
{ key: 'versionNo', label: 'history:version' },
|
||||||
|
{ key: 'kind', label: 'counts:columns.kind' },
|
||||||
|
{ key: 'name', label: 'history:name' },
|
||||||
|
{ key: 'category', label: 'common:categoryLabel' },
|
||||||
|
{ key: 'sections', label: 'counts:columns.sections', children: [
|
||||||
|
{ key: 'beginMs', label: 'counts:columns.begin' },
|
||||||
|
{ key: 'endMs', label: 'counts:columns.end' }] },
|
||||||
|
{ key: 'names', label: 'counts:columns.names', children: nameFields }],
|
||||||
|
names: nameFields,
|
||||||
|
aliases: [{ key: 'languageCode', label: 'counts:columns.language' }, ...aliasFields],
|
||||||
|
parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
|
||||||
|
|
||||||
|
|
||||||
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
|
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
|
||||||
'created_at', 'resource_id', 'version_no', 'user',
|
...(resource === 'posts' ? ['thumbnail'] as const : []),
|
||||||
|
'resource_id', 'version_no', 'is_latest',
|
||||||
...(resource === 'posts'
|
...(resource === 'posts'
|
||||||
? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const
|
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||||
: ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)]
|
'original_created_at', 'original_created_from', 'original_created_before'] as const
|
||||||
|
: ['name', 'names', 'category', 'aliases', 'parents', 'deprecated'] as const),
|
||||||
|
'user', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
export const fieldsForCounts = (resource: CountsResource): CountField[] =>
|
||||||
|
columnsForCounts (resource).map (key => ({
|
||||||
|
key,
|
||||||
|
label: (key === 'parents' && resource === 'tags') ? 'tags:parents' : countColumns[key],
|
||||||
|
children: nested[key] }))
|
||||||
|
|
||||||
|
|
||||||
|
export const selectedCountColumns = (
|
||||||
|
resource: CountsResource,
|
||||||
|
params: URLSearchParams,
|
||||||
|
): CountColumn[] => {
|
||||||
|
const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
|
||||||
|
columnsForCounts (resource).filter (key => ![
|
||||||
|
'thumbnail', 'original_created_from', 'original_created_before']
|
||||||
|
.includes (key))
|
||||||
|
return columnsForCounts (resource).filter (key => selected.includes (key))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
|
||||||
|
fields.flatMap (field => {
|
||||||
|
const path = prefix ? `${prefix}.${field.key}` : field.key
|
||||||
|
return [path, ...countChildPaths (field.children ?? [], path)]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const selectedCountChildren = (params: URLSearchParams): Set<string> =>
|
||||||
|
new Set (params.has ('children') ? params.get ('children')!.split (',') :
|
||||||
|
Object.entries (nested).flatMap (([key, fields]) =>
|
||||||
|
countChildPaths (fields, key)))
|
||||||
|
|
||||||
|
|
||||||
|
export const readCountSort = (value: string | null): CountSort[] =>
|
||||||
|
(value ?? '').split (',').flatMap (entry => {
|
||||||
|
const [key, direction] = entry.split (':')
|
||||||
|
return (key && (direction === 'asc' || direction === 'desc'))
|
||||||
|
? [{ key, direction }] : []
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
export const writeCountSort = (sort: CountSort[]): string =>
|
||||||
|
sort.map (item => `${item.key}:${item.direction}`).join (',')
|
||||||
|
|
||||||
|
|
||||||
|
export const changeCountSort = (
|
||||||
|
sort: CountSort[],
|
||||||
|
key: string,
|
||||||
|
additive: boolean,
|
||||||
|
): CountSort[] => {
|
||||||
|
const previous = sort.find (item => item.key === key)
|
||||||
|
if (!additive)
|
||||||
|
return [{ key, direction: previous?.direction === 'asc' ? 'desc' : 'asc' }]
|
||||||
|
if (previous == null)
|
||||||
|
return [...sort, { key, direction: 'asc' }]
|
||||||
|
return previous.direction === 'desc'
|
||||||
|
? sort.filter (item => item.key !== key)
|
||||||
|
: sort.map (item => item.key === key ? { key, direction: 'desc' } : item)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const normaliseCountsParams = (
|
export const normaliseCountsParams = (
|
||||||
@@ -40,25 +166,75 @@ export const normaliseCountsParams = (
|
|||||||
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
||||||
params.set ('axis', 'month')
|
params.set ('axis', 'month')
|
||||||
if (params.has ('columns'))
|
if (params.has ('columns'))
|
||||||
{
|
params.set ('columns', selectedCountColumns (resource, params).join (','))
|
||||||
const available = columnsForCounts (resource)
|
|
||||||
const columns = params.get ('columns')!.split (',')
|
|
||||||
.filter (column => available.includes (column as CountColumn))
|
|
||||||
if (columns.length > 0)
|
|
||||||
params.set ('columns', [...new Set (columns)].join (','))
|
|
||||||
else
|
|
||||||
params.delete ('columns')
|
|
||||||
}
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||||
|
const result = new URLSearchParams (params)
|
||||||
|
for (const key of ['columns', 'children', 'child_sort'])
|
||||||
|
result.delete (key)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const fetchCounts = (
|
export const fetchCounts = (
|
||||||
resource: CountsResource,
|
resource: CountsResource,
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
locale: LocaleCode,
|
locale: LocaleCode,
|
||||||
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
|
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
|
||||||
locale, params: Object.fromEntries (params) })
|
locale, params: Object.fromEntries (countsRequestParams (params)) })
|
||||||
|
|
||||||
|
|
||||||
|
const stableValue = (value: unknown): unknown => {
|
||||||
|
if (Array.isArray (value)) return value.map (stableValue)
|
||||||
|
if (value != null && typeof value === 'object')
|
||||||
|
{
|
||||||
|
return Object.fromEntries (Object.entries (value)
|
||||||
|
.sort (([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
|
||||||
|
.map (([key, entry]) => [key, stableValue (entry)]))
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const compareValue = (first: unknown, second: unknown): number => {
|
||||||
|
if (Array.isArray (first) && Array.isArray (second) && first.length !== second.length)
|
||||||
|
return first.length - second.length
|
||||||
|
if (typeof first === 'number' && typeof second === 'number') return first - second
|
||||||
|
if (typeof first === 'boolean' && typeof second === 'boolean')
|
||||||
|
return Number (first) - Number (second)
|
||||||
|
const left = typeof first === 'object' ? JSON.stringify (stableValue (first)) : String (first)
|
||||||
|
const right = typeof second === 'object'
|
||||||
|
? JSON.stringify (stableValue (second)) : String (second)
|
||||||
|
return left < right ? -1 : (left > right ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const sortedCountChildren = (
|
||||||
|
rows: CountRecord[],
|
||||||
|
path: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
valueFor: (row: CountRecord, key: string) => unknown = (row, key) => row[key],
|
||||||
|
): CountRecord[] => {
|
||||||
|
const sort = readCountSort (params.get ('child_sort'))
|
||||||
|
.filter (item => item.key.startsWith (`${path}/`))
|
||||||
|
return rows.map ((row, index) => ({ row, index })).sort ((first, second) => {
|
||||||
|
for (const item of sort)
|
||||||
|
{
|
||||||
|
const key = item.key.slice (path.length + 1)
|
||||||
|
const left = valueFor (first.row, key)
|
||||||
|
const right = valueFor (second.row, key)
|
||||||
|
if (left == null && right != null) return 1
|
||||||
|
if (left != null && right == null) return -1
|
||||||
|
if (left == null && right == null) continue
|
||||||
|
const compared = compareValue (left, right)
|
||||||
|
if (compared !== 0) return item.direction === 'asc' ? compared : -compared
|
||||||
|
}
|
||||||
|
return first.index - second.index
|
||||||
|
}).map (item => item.row)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const countsCSV = (rows: string[][]): string =>
|
export const countsCSV = (rows: string[][]): string =>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { fieldsForCounts, selectedCountChildren, sortedCountChildren } from '@/lib/counts'
|
||||||
|
import { translateUIMessage } from '@/lib/i18n'
|
||||||
|
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
||||||
|
|
||||||
|
import type { CountColumn, CountField, CountRecord,
|
||||||
|
CountsResource, CountsVersion } from '@/lib/counts'
|
||||||
|
import type { LocaleCode } from '@/lib/locales'
|
||||||
|
|
||||||
|
|
||||||
|
export const countsCellText = (
|
||||||
|
row: CountsVersion,
|
||||||
|
column: CountColumn,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): string => {
|
||||||
|
switch (column)
|
||||||
|
{
|
||||||
|
case 'resource_id': return String (row.resourceId)
|
||||||
|
case 'version_no': return String (row.versionNo)
|
||||||
|
case 'is_latest':
|
||||||
|
return translateUIMessage (`boolean.${row.isLatest}`, 'counts', locale).text
|
||||||
|
case 'title': return row.title ?? ''
|
||||||
|
case 'url': return row.url ?? ''
|
||||||
|
case 'thumbnail':
|
||||||
|
case 'thumbnail_base': return row.thumbnailBase ?? ''
|
||||||
|
case 'name': return row.name ?? ''
|
||||||
|
case 'category':
|
||||||
|
return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
|
||||||
|
case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
|
||||||
|
case 'original_created_at':
|
||||||
|
return originalCreatedAtString (
|
||||||
|
row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale)
|
||||||
|
case 'original_created_from':
|
||||||
|
case 'original_created_before':
|
||||||
|
{
|
||||||
|
const value = column === 'original_created_from'
|
||||||
|
? row.originalCreatedFrom : row.originalCreatedBefore
|
||||||
|
return value == null ? '' : localisedDateString (value, locale)
|
||||||
|
}
|
||||||
|
case 'deprecated':
|
||||||
|
return row.deprecated == null ? '' : localisedDateString (row.deprecated, locale)
|
||||||
|
case 'user':
|
||||||
|
if (row.createdByUser == null)
|
||||||
|
return translateUIMessage ('history:bot', 'counts', locale).text
|
||||||
|
return row.createdByUser.name
|
||||||
|
? `${row.createdByUser.name} (#${row.createdByUser.id})`
|
||||||
|
: translateUIMessage ('history:anonymous', 'counts', locale, {
|
||||||
|
id: row.createdByUser.id }).text
|
||||||
|
case 'created_at': return localisedDateString (row.createdAt, locale)
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsCSVCell = (
|
||||||
|
row: CountsVersion,
|
||||||
|
column: CountColumn,
|
||||||
|
resource: CountsResource,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): string => {
|
||||||
|
const field = fieldsForCounts (resource).find (field => field.key === column)!
|
||||||
|
if (field.children != null)
|
||||||
|
{
|
||||||
|
const rows = row[column as keyof CountsVersion] as CountRecord[] ?? []
|
||||||
|
return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
|
||||||
|
}
|
||||||
|
return countsCellText (row, column, locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const displayNames = new Map<string, Intl.DisplayNames> ()
|
||||||
|
|
||||||
|
|
||||||
|
export const countsLogicalName = (
|
||||||
|
row: CountRecord,
|
||||||
|
field: string,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): { text: string; language?: string } => {
|
||||||
|
const value = row[field]
|
||||||
|
if (value == null) return { text: '' }
|
||||||
|
const code = String (value)
|
||||||
|
if (field === 'languageCode' || field === 'scriptCode')
|
||||||
|
{
|
||||||
|
if (field === 'languageCode' && ['ja', 'ain'].includes (code))
|
||||||
|
return translateUIMessage (`history:languageNames.${code}`, 'counts', locale)
|
||||||
|
const master = row[field === 'languageCode' ? 'languageName' : 'scriptName']
|
||||||
|
if (typeof master === 'string' && master.trim () !== ''
|
||||||
|
&& master.toLowerCase () !== code.toLowerCase ())
|
||||||
|
return { text: master }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const type = field === 'languageCode' ? 'language' : 'script'
|
||||||
|
if (!displayNames.has (type))
|
||||||
|
displayNames.set (type, new Intl.DisplayNames (['ja'], { type, fallback: 'none' }))
|
||||||
|
const text = displayNames.get (type)!.of (code)
|
||||||
|
if (text != null && text !== code) return { text, language: 'ja' }
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Invalid or unregistered codes have no logical label; never expose the code as a name.
|
||||||
|
}
|
||||||
|
return translateUIMessage ('notRegistered', 'counts', locale)
|
||||||
|
}
|
||||||
|
if (field === 'kind' || field === 'category')
|
||||||
|
return translateUIMessage (field === 'kind' ? `kinds.${code}`
|
||||||
|
: `common:categories.${code}`, 'counts', locale)
|
||||||
|
if (typeof value === 'boolean')
|
||||||
|
return translateUIMessage (`boolean.${value}`, 'counts', locale)
|
||||||
|
if (field === 'beginMs' || field === 'endMs') return { text: msToTime (Number (value)) }
|
||||||
|
return { text: String (value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsNestedOrder = (
|
||||||
|
rows: CountRecord[],
|
||||||
|
path: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
|
||||||
|
['languageCode', 'scriptCode'].includes (key)
|
||||||
|
? countsLogicalName (row, key, locale).text : row[key])
|
||||||
|
|
||||||
|
|
||||||
|
export const countsNestedExport = (
|
||||||
|
rows: CountRecord[],
|
||||||
|
fields: CountField[],
|
||||||
|
path: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): unknown => {
|
||||||
|
const selected = selectedCountChildren (params)
|
||||||
|
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||||
|
if (columns.length === 0)
|
||||||
|
return { [translateUIMessage ('common:countLabel', 'counts', locale).text]: rows.length }
|
||||||
|
return countsNestedOrder (rows, path, params, locale).map (row =>
|
||||||
|
Object.fromEntries (columns.map (field => [
|
||||||
|
translateUIMessage (field.label, 'counts', locale).text,
|
||||||
|
field.children
|
||||||
|
? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
|
||||||
|
`${path}.${field.key}`, params, locale)
|
||||||
|
: countsLogicalName (row, field.key, locale).text])))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsNestedText = (
|
||||||
|
rows: CountRecord[],
|
||||||
|
fields: CountField[],
|
||||||
|
path: string,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): string => {
|
||||||
|
const selected = selectedCountChildren (params)
|
||||||
|
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||||
|
if (columns.length === 0)
|
||||||
|
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
|
||||||
|
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
|
||||||
|
const value = field.children
|
||||||
|
? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
|
||||||
|
`${path}.${field.key}`, params, locale)
|
||||||
|
: countsLogicalName (row, field.key, locale).text
|
||||||
|
return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
|
||||||
|
}).join (' | ')).join ('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const countsCellCopy = (
|
||||||
|
row: CountsVersion,
|
||||||
|
field: CountField,
|
||||||
|
params: URLSearchParams,
|
||||||
|
locale: LocaleCode,
|
||||||
|
): string => field.children
|
||||||
|
? countsNestedText (row[field.key as keyof CountsVersion] as CountRecord[] ?? [],
|
||||||
|
field.children, field.key, params, locale)
|
||||||
|
: countsCellText (row, field.key as CountColumn, locale)
|
||||||
|
|
||||||
|
|
||||||
|
// Historical URLs are displayed without fetching current attachments or metadata.
|
||||||
|
export const countsImageURL = (value: string | null | undefined): string | null => {
|
||||||
|
if (!value) return null
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const url = new URL (value)
|
||||||
|
return (['https:', 'http:'].includes (url.protocol) && !url.username && !url.password)
|
||||||
|
? url.href : null
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
|
||||||
|
export const MIN_COUNT_WIDTH = 64
|
||||||
|
export const MAX_COUNT_WIDTH = 2000
|
||||||
|
|
||||||
|
export const defaultCountWidths: Record<string, number> = {
|
||||||
|
thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
||||||
|
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
||||||
|
video_ms: 120, original_created_at: 260, original_created_from: 240,
|
||||||
|
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||||
|
aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
|
||||||
|
id: 88, versionNo: 88, kind: 140, sections: 240, beginMs: 120, endMs: 120,
|
||||||
|
languageCode: 180, scriptCode: 200, autoGenerated: 120 }
|
||||||
|
|
||||||
|
type Layout = {
|
||||||
|
width: (family: string, key: string) => number
|
||||||
|
resize: (family: string, key: string, width: number) => void }
|
||||||
|
export const CountsLayoutContext = createContext<Layout | null> (null)
|
||||||
|
export const CountsTableScope = createContext ('')
|
||||||
|
|
||||||
|
|
||||||
|
export const useCountsTableLayout = (): Layout => {
|
||||||
|
const context = useContext (CountsLayoutContext)
|
||||||
|
if (context == null) throw new Error ('Counts table layout provider is missing')
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+26
-26
@@ -91,36 +91,36 @@ export const originalCreatedAtString = (
|
|||||||
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
|
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
|
||||||
const space = locale === 'ja' ? ' ' : '\u3000'
|
const space = locale === 'ja' ? ' ' : '\u3000'
|
||||||
|
|
||||||
if (from && before)
|
const fromParts = from == null ? null : new Date (from.getTime () + 9 * 3_600_000)
|
||||||
|
const beforeParts = before == null ? null : new Date (before.getTime () + 9 * 3_600_000)
|
||||||
|
|
||||||
|
if (from && before && fromParts && beforeParts)
|
||||||
|
{
|
||||||
|
const diff = before.getTime () - from.getTime ()
|
||||||
|
if (diff <= 60_000 /* 1 分 */)
|
||||||
|
return format (from, 'second')
|
||||||
|
if (fromParts.getUTCMinutes () === 0 && beforeParts.getUTCMinutes () === 0)
|
||||||
{
|
{
|
||||||
const diff = before.getTime () - from.getTime ()
|
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
|
||||||
|
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
|
||||||
if (diff <= 60_000 /* 1 分 */)
|
if (fromParts.getUTCHours () === 0 && beforeParts.getUTCHours () === 0)
|
||||||
return format (from, 'second')
|
{
|
||||||
|
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
|
||||||
if (from.getMinutes () === 0 && before.getMinutes () === 0)
|
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
|
||||||
|
if (fromParts.getUTCDate () === 1 && beforeParts.getUTCDate () === 1)
|
||||||
{
|
{
|
||||||
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
|
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
|
||||||
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
|
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
|
||||||
|
if (fromParts.getUTCMonth () === 0 && beforeParts.getUTCMonth () === 0
|
||||||
if (from.getHours () === 0 && before.getHours () === 0)
|
&& (31_449_600_000 /* 364 日 */ <= diff
|
||||||
{
|
&& diff < 31_708_800_000 /* 367 日 */))
|
||||||
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
|
{
|
||||||
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
|
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
|
||||||
|
}
|
||||||
if (from.getDate () === 1 && before.getDate () === 1)
|
|
||||||
{
|
|
||||||
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
|
|
||||||
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
|
|
||||||
|
|
||||||
if (from.getMonth () === 0 && before.getMonth () === 0
|
|
||||||
&& (31_449_600_000 /* 364 日 */ <= diff
|
|
||||||
&& diff < 31_708_800_000 /* 367 日 */))
|
|
||||||
return `${format (from, 'month')}${space}(${unknownLabel ('unknownMonthDay')})`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rtn = ([from ? format (from, 'second') : '',
|
const rtn = ([from ? format (from, 'second') : '',
|
||||||
'〜',
|
'〜',
|
||||||
|
|||||||
@@ -22,14 +22,23 @@
|
|||||||
"csvScope": "表示中のページ・選択列を CSV 出力",
|
"csvScope": "表示中のページ・選択列を CSV 出力",
|
||||||
"empty": "該当する履歴はありません。",
|
"empty": "該当する履歴はありません。",
|
||||||
"columns": {
|
"columns": {
|
||||||
"changedAt": "変更日時",
|
"changedAt": "操作日時",
|
||||||
"resourceId": "ID",
|
"resourceId": "Id.",
|
||||||
"postTitle": "投稿タイトル",
|
"postTitle": "投稿タイトル",
|
||||||
"postURL": "投稿 URL",
|
"postURL": "投稿 URL",
|
||||||
"tags": "記載タグ",
|
"tags": "記載タグ",
|
||||||
"addedTags": "追加されたタグ",
|
"deprecated": "廃止状態",
|
||||||
"removedTags": "削除されたタグ",
|
"latest": "最新フラグ",
|
||||||
"deprecated": "廃止状態"
|
"thumbnailURL": "サムネール元 URL",
|
||||||
|
"originalFrom": "オリジナルの投稿日時(開始境界)",
|
||||||
|
"originalBefore": "オリジナルの投稿日時(終了境界・未満)",
|
||||||
|
"language": "言語",
|
||||||
|
"kind": "内部・外部",
|
||||||
|
"sections": "記載区間",
|
||||||
|
"begin": "開始",
|
||||||
|
"end": "終了",
|
||||||
|
"names": "言語別名称",
|
||||||
|
"thumbnailDisplay": "サムネール表示"
|
||||||
},
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"from": "開始(端末の時刻帯・以上)",
|
"from": "開始(端末の時刻帯・以上)",
|
||||||
@@ -38,5 +47,37 @@
|
|||||||
"tag": "タグ名または ID",
|
"tag": "タグ名または ID",
|
||||||
"actor": "操作者 ID",
|
"actor": "操作者 ID",
|
||||||
"limit": "表示件数"
|
"limit": "表示件数"
|
||||||
|
},
|
||||||
|
"latestOnly": "最新のみ",
|
||||||
|
"boolean": {
|
||||||
|
"true": "はい",
|
||||||
|
"false": "いいえ"
|
||||||
|
},
|
||||||
|
"kinds": {
|
||||||
|
"internal": "内部タグ",
|
||||||
|
"external": "外部タグ"
|
||||||
|
},
|
||||||
|
"nested": {
|
||||||
|
"toggle": "表内表を展開・折り畳み",
|
||||||
|
"selection": "親を OFF にしても子項目の選択は保持します。子項目がすべて OFF の表は件数のみ表示します。",
|
||||||
|
"noColumns": "出力項目が選択されていません。"
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"action": "クリックで単独ソート。Ctrl または長押しでキーを追加・方向変更・解除。NULL は末尾。",
|
||||||
|
"structured": "親表の複数値は要素数、保存スナップショットの文字列表現の順で比較します。Ctrl または長押しで複数キーソート。子表のソートは親表に影響しません。",
|
||||||
|
"label": "{{column}}:{{direction}}、優先順位 {{priority}}",
|
||||||
|
"none": "ソートなし",
|
||||||
|
"ascending": "昇順",
|
||||||
|
"descending": "降順",
|
||||||
|
"unsortedLabel": "{{column}}:ソートなし",
|
||||||
|
"singleLabel": "{{column}}:{{direction}}"
|
||||||
|
},
|
||||||
|
"notRegistered": "名称未登録",
|
||||||
|
"selection": {
|
||||||
|
"table": "集計・明細表",
|
||||||
|
"hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。"
|
||||||
|
},
|
||||||
|
"resize": {
|
||||||
|
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,247 +1,72 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useEffect, useId, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import LocalisedText from '@/components/LocalisedText'
|
|
||||||
import PrefetchLink from '@/components/PrefetchLink'
|
import PrefetchLink from '@/components/PrefetchLink'
|
||||||
import DateTimeField from '@/components/common/DateTimeField'
|
import CountsControls from '@/components/counts/CountsControls'
|
||||||
import FormField from '@/components/common/FormField'
|
import CountsResults from '@/components/counts/CountsResults'
|
||||||
|
import CountsTableLayout from '@/components/counts/CountsTableLayout'
|
||||||
import PageHead from '@/components/common/PageHead'
|
import PageHead from '@/components/common/PageHead'
|
||||||
import PageTitle from '@/components/common/PageTitle'
|
import PageTitle from '@/components/common/PageTitle'
|
||||||
import Pagination from '@/components/common/Pagination'
|
import Pagination from '@/components/common/Pagination'
|
||||||
import MainArea from '@/components/layout/MainArea'
|
import MainArea from '@/components/layout/MainArea'
|
||||||
import SidebarComponent from '@/components/layout/SidebarComponent'
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { columnsForCounts, countColumns, countsCSV,
|
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
|
||||||
fetchCounts, normaliseCountsParams } from '@/lib/counts'
|
normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
|
import { countsCSVCell } from '@/lib/countsDisplay'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
import { fetchTagByName } from '@/lib/tags'
|
import { cn } from '@/lib/utils'
|
||||||
import { cn, inputClass, localisedDateString } from '@/lib/utils'
|
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
|
|
||||||
import type { CountColumn, CountsResource } from '@/lib/counts'
|
import type { CountsResource } from '@/lib/counts'
|
||||||
import type { PostVersion, TagVersion } from '@/types'
|
|
||||||
|
|
||||||
|
|
||||||
const CountsControls = (
|
|
||||||
{ resource, applied, open, setOpen, execute }: {
|
|
||||||
resource: CountsResource
|
|
||||||
applied: URLSearchParams
|
|
||||||
open: boolean
|
|
||||||
setOpen: (open: boolean) => void
|
|
||||||
execute: (params: URLSearchParams) => void },
|
|
||||||
) => {
|
|
||||||
const { label, message } = useUITranslation ('counts')
|
|
||||||
const locale = useLocale ()
|
|
||||||
const id = useId ()
|
|
||||||
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
|
||||||
const [error, setError] = useState (false)
|
|
||||||
const [pending, setPending] = useState (false)
|
|
||||||
const available = columnsForCounts (resource)
|
|
||||||
const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[]
|
|
||||||
const mode = draft.get ('mode') ?? 'summary'
|
|
||||||
const update = (key: string, value: string) => {
|
|
||||||
setDraft (previous => {
|
|
||||||
const next = new URLSearchParams (previous)
|
|
||||||
if (value) next.set (key, value)
|
|
||||||
else next.delete (key)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const submit = async () => {
|
|
||||||
setError (false)
|
|
||||||
setPending (true)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
const next = new URLSearchParams (draft)
|
|
||||||
for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
|
|
||||||
{
|
|
||||||
const value = next.get (key)?.trim () ?? ''
|
|
||||||
if (value && !/^[1-9][0-9]*$/.test (value))
|
|
||||||
{
|
|
||||||
const tag = await fetchTagByName (value, locale)
|
|
||||||
if (tag == null)
|
|
||||||
throw new Error ()
|
|
||||||
next.set (key, String (tag.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
next.delete ('page')
|
|
||||||
execute (normaliseCountsParams (resource, next))
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
setError (true)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
setPending (false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="shrink-0" onSubmit={event => {
|
|
||||||
event.preventDefault ()
|
|
||||||
void submit ()
|
|
||||||
}}>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-b pb-2 dark:border-slate-700">
|
|
||||||
<Button type="submit" size="sm" disabled={pending}>{label ('run')}</Button>
|
|
||||||
<Button type="button" size="sm" variant="outline" aria-expanded={open}
|
|
||||||
aria-controls={id} onClick={() => setOpen (!open)}>
|
|
||||||
{label ('conditions')}<span aria-hidden>{open ? '▴' : '▾'}</span>
|
|
||||||
</Button>
|
|
||||||
<select className={inputClass (false, 'w-auto py-1')}
|
|
||||||
aria-label={message ('mode').text} lang={message ('mode').language}
|
|
||||||
value={mode} onChange={event => update ('mode', event.target.value)}>
|
|
||||||
<option value="summary" lang={message ('summary').language}>
|
|
||||||
{message ('summary').text}</option>
|
|
||||||
<option value="details" lang={message ('details').language}>
|
|
||||||
{message ('details').text}</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
|
||||||
<div id={id} hidden={!open} className="border-b py-2 dark:border-slate-700">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<fieldset className="min-w-0" data-counts-output>
|
|
||||||
<legend className="mb-2 font-semibold">{label ('output')}</legend>
|
|
||||||
{mode === 'summary' ? (
|
|
||||||
<label className="flex flex-wrap items-center gap-2">
|
|
||||||
{label ('axis')}
|
|
||||||
<select className={inputClass (false, 'w-auto py-1')}
|
|
||||||
value={draft.get ('axis') ?? 'month'}
|
|
||||||
aria-label={message ('axis').text} lang={message ('axis').language}
|
|
||||||
onChange={event => update ('axis', event.target.value)}>
|
|
||||||
{['month', 'user'].map (axis => (
|
|
||||||
<option key={axis} value={axis} lang={message (`axes.${axis}`).language}>
|
|
||||||
{message (`axes.${axis}`).text}</option>))}
|
|
||||||
</select>
|
|
||||||
</label>) : (
|
|
||||||
<div className="grid gap-1 md:grid-cols-2">
|
|
||||||
{available.map (column => (
|
|
||||||
<label key={column} className="flex items-center gap-1">
|
|
||||||
<input type="checkbox" checked={columns.includes (column)}
|
|
||||||
disabled={columns.length === 1 && columns.includes (column)}
|
|
||||||
onChange={event => update ('columns', (event.target.checked
|
|
||||||
? [...columns, column]
|
|
||||||
: columns.filter (item => item !== column)).join (','))}/>
|
|
||||||
{label (countColumns[column])}
|
|
||||||
</label>))}
|
|
||||||
</div>)}
|
|
||||||
</fieldset>
|
|
||||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
|
||||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
|
||||||
{['from', 'before'].map (key => (
|
|
||||||
<FormField key={key} label={label (`fields.${key}`)}>
|
|
||||||
{() => <DateTimeField value={draft.get (key) ?? ''}
|
|
||||||
aria-label={message (`fields.${key}`).text}
|
|
||||||
lang={message (`fields.${key}`).language}
|
|
||||||
onChange={value => update (key, value ?? '')}/>}
|
|
||||||
</FormField>))}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 md:grid-cols-2">
|
|
||||||
{[['resource_id', resource === 'posts' ? 'post' : 'tag'], ['user_id', 'actor'],
|
|
||||||
...(resource === 'posts' ? [['tag', 'tag']] : []), ['limit', 'limit']]
|
|
||||||
.map (([key, field]) => (
|
|
||||||
<FormField key={key} label={label (`fields.${field}`)}>
|
|
||||||
{() => <input aria-label={message (`fields.${field}`).text}
|
|
||||||
lang={message (`fields.${field}`).language}
|
|
||||||
type={key === 'limit' ? 'number' : 'text'} min={1} max={100}
|
|
||||||
value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
|
|
||||||
onChange={event => update (key, event.target.value)}
|
|
||||||
className={inputClass (false, 'py-1')}/>}
|
|
||||||
</FormField>))}
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||||
const [source, setParams] = useSearchParams ()
|
const [source, setParams] = useSearchParams ()
|
||||||
const params = normaliseCountsParams (resource, source)
|
const params = normaliseCountsParams (resource, source)
|
||||||
const canonical = params.toString ()
|
const canonical = params.toString ()
|
||||||
|
const request = countsRequestParams (params)
|
||||||
const [open, setOpen] = useState (false)
|
const [open, setOpen] = useState (false)
|
||||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { label, message } = useUITranslation ('counts')
|
const { label, message } = useUITranslation ('counts')
|
||||||
const mode = params.get ('mode') ?? 'summary'
|
const mode = params.get ('mode') ?? 'summary'
|
||||||
const axis = params.get ('axis') ?? 'month'
|
const axis = params.get ('axis') ?? 'month'
|
||||||
const columns = (params.get ('columns')?.split (',')
|
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
|
||||||
?? columnsForCounts (resource)) as CountColumn[]
|
queryKey: countsKeys.index (resource, request.toString (), locale),
|
||||||
const { data, isPending, isError, refetch } = useQuery ({
|
queryFn: () => fetchCounts (resource, request, locale), retry: false,
|
||||||
queryKey: countsKeys.index (resource, canonical, locale),
|
placeholderData: (previous, query) => {
|
||||||
queryFn: () => fetchCounts (resource, params, locale), retry: false })
|
const previousMode = new URLSearchParams (String (query?.queryKey[3] ?? ''))
|
||||||
|
.get ('mode') ?? 'summary'
|
||||||
|
return (query?.queryKey[1] === resource && query?.queryKey[2] === locale
|
||||||
|
&& previousMode === mode) ? previous : undefined
|
||||||
|
} })
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (source.toString () !== canonical)
|
if (source.toString () !== canonical)
|
||||||
setParams (canonical, { replace: true })
|
setParams (canonical, { replace: true })
|
||||||
}, [canonical, source, setParams])
|
}, [canonical, source, setParams])
|
||||||
|
|
||||||
const cell = (row: PostVersion | TagVersion, column: CountColumn): string => {
|
const update = (key: string, value: string) => {
|
||||||
switch (column)
|
|
||||||
{
|
|
||||||
case 'created_at': return localisedDateString (row.createdAt, locale)
|
|
||||||
case 'resource_id': return String ('postId' in row ? row.postId : row.tagId)
|
|
||||||
case 'version_no': return String (row.versionNo)
|
|
||||||
case 'user': return row.createdByUser?.name ?? ''
|
|
||||||
}
|
|
||||||
if ('postId' in row)
|
|
||||||
{
|
|
||||||
if (column === 'title') return row.title.current ?? ''
|
|
||||||
if (column === 'url') return row.url.current
|
|
||||||
const tags = row.tags.filter (tag => column === 'added_tags'
|
|
||||||
? tag.type === 'added'
|
|
||||||
: (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed'))
|
|
||||||
return tags.map (tag => tag.name).join (' ')
|
|
||||||
}
|
|
||||||
switch (column)
|
|
||||||
{
|
|
||||||
case 'name': return row.name.current ?? ''
|
|
||||||
case 'category': return message (`common:categories.${row.category.current}`).text
|
|
||||||
case 'aliases':
|
|
||||||
return row.aliases.filter (item => item.type !== 'removed')
|
|
||||||
.map (item => item.name).join (' ')
|
|
||||||
case 'parents':
|
|
||||||
return row.parentTags.filter (item => item.type !== 'removed')
|
|
||||||
.map (item => item.tag.name).join (' ')
|
|
||||||
case 'deprecated': return row.deprecatedAt.current ?? ''
|
|
||||||
default: return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const groups = data?.groups ?? []
|
|
||||||
const versions = data?.versions ?? []
|
|
||||||
const groupName = (group: typeof groups[number]) =>
|
|
||||||
axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key)
|
|
||||||
const drillDown = (key: string | number | null): string => {
|
|
||||||
const next = new URLSearchParams (params)
|
const next = new URLSearchParams (params)
|
||||||
next.set ('mode', 'details')
|
if (value || key === 'sort') next.set (key, value)
|
||||||
next.delete ('page')
|
else next.delete (key)
|
||||||
if (axis === 'month')
|
if (key === 'sort') next.delete ('page')
|
||||||
{
|
setParams (next)
|
||||||
const first = `${key}-01T00:00Z`
|
|
||||||
const date = new Date (first)
|
|
||||||
date.setUTCMonth (date.getUTCMonth () + 1)
|
|
||||||
const last = date.toISOString ().slice (0, 16) + 'Z'
|
|
||||||
const from = next.get ('from')
|
|
||||||
const before = next.get ('before')
|
|
||||||
next.set ('from', from != null && from > first ? from : first)
|
|
||||||
next.set ('before', before != null && before < last ? before : last)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
next.set ('user_id', String (key ?? 'none'))
|
|
||||||
return `?${next}`
|
|
||||||
}
|
}
|
||||||
const groupText = (group: typeof groups[number]) =>
|
|
||||||
group.key == null ? message ('history:bot').text : groupName (group)
|
|
||||||
const exportCSV = () => {
|
const exportCSV = () => {
|
||||||
|
const columns = selectedCountColumns (resource, params)
|
||||||
const rows = mode === 'summary'
|
const rows = mode === 'summary'
|
||||||
? [[message (`axes.${axis}`).text, message ('metric').text],
|
? [[message (`axes.${axis}`).text, message ('metric').text],
|
||||||
...groups.map (group => [groupText (group), String (group.count)])]
|
...(data?.groups ?? []).map (group => [
|
||||||
: [columns.map (column => message (countColumns[column]).text),
|
group.key == null ? message ('history:bot').text : group.name ?? String (group.key),
|
||||||
...versions.map (row => columns.map (column => cell (row, column)))]
|
String (group.count)])]
|
||||||
|
: [columns.map (column => message (
|
||||||
|
fieldsForCounts (resource).find (field => field.key === column)!.label).text),
|
||||||
|
...(data?.versions ?? []).map (row => columns.map (
|
||||||
|
column => countsCSVCell (row, column, resource, params, locale)))]
|
||||||
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
|
const url = URL.createObjectURL (new Blob ([countsCSV (rows)], {
|
||||||
type: 'text/csv;charset=utf-8' }))
|
type: 'text/csv;charset=utf-8' }))
|
||||||
const link = document.createElement ('a')
|
const link = document.createElement ('a')
|
||||||
@@ -252,91 +77,54 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-sidebar-container
|
<CountsTableLayout>
|
||||||
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
|
<div data-sidebar-container
|
||||||
<PageHead title={message (`titles.${resource}`)}/>
|
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
|
||||||
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
<PageHead title={message (`titles.${resource}`)}/>
|
||||||
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
|
||||||
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
aria-expanded={sidebarOpen} aria-controls="counts-targets"
|
||||||
<SidebarComponent sidebarKey="counts" maxWidth={224}
|
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
|
||||||
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
|
<SidebarComponent sidebarKey="counts" maxWidth={224}
|
||||||
<nav id="counts-targets" aria-label={message ('target').text}
|
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
|
||||||
lang={message ('target').language}>
|
<nav id="counts-targets" aria-label={message ('target').text}
|
||||||
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
lang={message ('target').language}>
|
||||||
<ul className="space-y-1">
|
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
||||||
{(['posts', 'tags'] as const).map (target => (
|
<ul className="space-y-1">
|
||||||
<li key={target}><PrefetchLink to={`/counts/${target}`}
|
{(['posts', 'tags'] as const).map (target => (
|
||||||
aria-current={target === resource ? 'page' : undefined}
|
<li key={target}><PrefetchLink to={`/counts/${target}`}
|
||||||
className={cn ('block py-1', target === resource && 'font-bold underline')}>
|
aria-current={target === resource ? 'page' : undefined}
|
||||||
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
|
className={cn (
|
||||||
</PrefetchLink></li>))}
|
'block py-1', target === resource && 'font-bold underline')}>
|
||||||
</ul>
|
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
|
||||||
</nav>
|
</PrefetchLink></li>))}
|
||||||
</SidebarComponent>
|
</ul>
|
||||||
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
</nav>
|
||||||
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
</SidebarComponent>
|
||||||
<div className="relative pr-16">
|
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
|
||||||
<CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
|
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
|
||||||
open={open} setOpen={setOpen} execute={next => {
|
<div className="relative shrink-0 pr-16">
|
||||||
if (next.toString () === canonical) void refetch ()
|
<CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
|
||||||
else setParams (next)
|
open={open} setOpen={setOpen} execute={next => {
|
||||||
}}/>
|
if (next.toString () === canonical) void refetch ()
|
||||||
<Button type="button" variant="outline" size="sm"
|
else setParams (next)
|
||||||
className="absolute right-0 top-0"
|
}}/>
|
||||||
title={message ('csvScope').text} lang={message ('csvScope').language}
|
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0"
|
||||||
disabled={data == null || isError} onClick={exportCSV}>{label ('csv')}</Button>
|
title={message ('csvScope').text} lang={message ('csvScope').language}
|
||||||
</div>
|
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}>
|
||||||
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
{label ('csv')}</Button>
|
||||||
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
|
||||||
{data != null && <>
|
|
||||||
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
|
|
||||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results>
|
|
||||||
<table className="w-full text-left text-sm">
|
|
||||||
<thead><tr>
|
|
||||||
{mode === 'summary'
|
|
||||||
? <><th className="px-2 py-1">{label (`axes.${axis}`)}</th>
|
|
||||||
<th className="px-2 py-1">{label ('metric')}</th></>
|
|
||||||
: <><th className="px-2 py-1">
|
|
||||||
{label (resource === 'posts' ? 'history:post' : 'navigation:tags')}</th>
|
|
||||||
{columns.map (column => (
|
|
||||||
<th key={column} className="whitespace-nowrap px-2 py-1">
|
|
||||||
{label (countColumns[column])}</th>))}</>}
|
|
||||||
</tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{mode === 'summary' ? groups.map (group => (
|
|
||||||
<tr key={String (group.key)} className="border-b dark:border-slate-700">
|
|
||||||
<td className="px-2 py-1"><PrefetchLink to={drillDown (group.key)}>
|
|
||||||
{group.key == null ? label ('history:bot') : groupName (group)}
|
|
||||||
</PrefetchLink></td>
|
|
||||||
<td className="px-2 py-1">{group.count}</td>
|
|
||||||
</tr>)) : versions.map (row => {
|
|
||||||
const id = 'postId' in row ? row.postId : row.tagId
|
|
||||||
return <tr key={`${id}-${row.versionNo}`}
|
|
||||||
className="border-b dark:border-slate-700">
|
|
||||||
<td className="px-2 py-1"><PrefetchLink
|
|
||||||
to={`/${resource}/${encodeURIComponent (id)}`}>#{id}</PrefetchLink></td>
|
|
||||||
{columns.map (column => (
|
|
||||||
<td key={column} className="max-w-md break-words px-2 py-1">
|
|
||||||
{column === 'url'
|
|
||||||
? <a href={cell (row, column)} target="_blank" rel="noreferrer">
|
|
||||||
{cell (row, column)}</a>
|
|
||||||
: (column === 'category' && 'tagId' in row
|
|
||||||
? <LocalisedText language={message (
|
|
||||||
`common:categories.${row.category.current}`).language}>
|
|
||||||
{cell (row, column)}</LocalisedText>
|
|
||||||
: cell (row, column))}</td>))}
|
|
||||||
</tr>
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{(mode === 'summary' ? groups.length : versions.length) === 0
|
|
||||||
&& <p className="p-2">{label ('empty')}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<Pagination page={data.page} totalPages={Math.ceil (
|
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
|
||||||
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
{isError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
</>}
|
{data != null && <>
|
||||||
</MainArea>
|
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
|
||||||
</div>)
|
<CountsResults resource={resource} data={data} params={params}
|
||||||
|
pending={isPlaceholderData} update={update}/>
|
||||||
|
<Pagination page={data.page} totalPages={Math.ceil (
|
||||||
|
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
|
||||||
|
</>}
|
||||||
|
</MainArea>
|
||||||
|
</div>
|
||||||
|
</CountsTableLayout>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする