コミットを比較

...
5 コミット
作成者 SHA1 メッセージ 日付
みてるぞ e71663bd22 #434 2026-10-25 16:47:05 +09:00
みてるぞ 52a8d62be5 #434 2026-10-25 16:25:04 +09:00
みてるぞ 3e05757820 #434 2026-10-25 16:00:37 +09:00
みてるぞ d411b3b40f #434 2026-10-25 14:53:35 +09:00
みてるぞ 304090f717 #434 2026-10-25 14:14:55 +09:00
38個のファイルの変更、2906行の追加、69行の削除
+17 -4
ファイルの表示
@@ -573,10 +573,10 @@ and layout reuse, follow `frontend/AGENTS.md`.
- When new user-facing wording appears necessary, ask the user for the exact
wording and placement before implementing it.
- 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
test-file scope and rerun them until they pass or the remaining failure is
clearly blocked.
- 明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コードも対象とする。
既存のテスト差分を勝手に修正・差戻ししない。
この規約はディレクトリ固有の自動的なテスト実行推奨より優先する。
- Test-only work includes adding, updating, deleting, reorganising, or fixing
SyntaxError in tests. During test-only work, do not modify production code.
- During test-only work, do not change production constants, behaviour, API
@@ -631,6 +631,19 @@ and layout reuse, follow `frontend/AGENTS.md`.
## Frontend rules
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
+20
ファイルの表示
@@ -0,0 +1,20 @@
module CountsListing
extend ActiveSupport::Concern
def index
locale = resolve_locale!
resource = is_a?(CountsPostsController) ? 'posts' : 'tags'
query = CountsQuery.new(version_scope, resource, request.query_parameters.except('locale')
.merge('locale' => locale.code))
result = { count: query.count, page: query.page, limit: query.limit }
if query.mode == 'summary'
result.merge!(query.summary)
else
result[:versions] = CountsVersionRepr.many(
query.details, resource:, language_code: locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
head :bad_request
end
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsPostsController < PostVersionsController
include CountsListing
end
+3
ファイルの表示
@@ -0,0 +1,3 @@
class CountsTagsController < TagVersionsController
include CountsListing
end
+19 -15
ファイルの表示
@@ -14,21 +14,7 @@ class PostVersionsController < ApplicationController
offset = (page - 1) * limit
q = PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
q = version_scope
q = q.where('post_versions.post_id = ?', post_id) if post_id
if external_tag_id || (tag_id && !(Tag.exists?(id: tag_id)))
q = q.where('JSON_CONTAINS(post_versions.tags_json,' +
@@ -51,6 +37,24 @@ class PostVersionsController < ApplicationController
private
def version_scope
PostVersion.joins(<<~SQL.squish)
LEFT JOIN
post_versions prev
ON
prev.post_id = post_versions.post_id
AND prev.version_no = post_versions.version_no - 1
SQL
.select('post_versions.*',
'prev.title AS prev_title',
'prev.url AS prev_url',
'prev.thumbnail_base AS prev_thumbnail_base',
'prev.tags_json AS prev_tags_json',
'prev.video_ms AS prev_video_ms',
'prev.original_created_from AS prev_original_created_from',
'prev.original_created_before AS prev_original_created_before')
end
def serialise_versions rows, language_code
rows = rows.to_a
user_ids = rows.map(&:created_by_user_id).compact.uniq
+15 -11
ファイルの表示
@@ -10,17 +10,7 @@ class TagVersionsController < ApplicationController
offset = (page - 1) * limit
q = TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
q = version_scope
q = q.where('tag_versions.tag_id = ?', tag_id) if tag_id
count = q.except(:select, :order, :limit, :offset).count
@@ -34,6 +24,20 @@ class TagVersionsController < ApplicationController
private
def version_scope
TagVersion.joins(<<~SQL.squish)
LEFT JOIN
tag_versions prev
ON
prev.tag_id = tag_versions.tag_id
AND prev.version_no = tag_versions.version_no - 1
SQL
.select('tag_versions.*', 'prev.name AS prev_name', 'prev.category AS prev_category',
'prev.deprecated_at AS prev_deprecated_at',
'prev.aliases AS prev_aliases', 'prev.names AS prev_names',
'prev.parent_tag_ids AS prev_parent_tag_ids')
end
def serialise_versions rows, language_code
rows = rows.to_a
tag_ids = rows.map(&:tag_id).uniq
+97
ファイルの表示
@@ -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
+198
ファイルの表示
@@ -0,0 +1,198 @@
# Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery
AXES = ['month', 'user'].freeze
COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'user', 'created_at'].freeze
COLUMNS = {
'posts' => COMMON_COLUMNS + [
'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 = [
'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze
attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params)
@table = scope.klass.table_name
@resource = resource
@id_column = resource == 'posts' ? 'post_id' : 'tag_id'
@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.values.all? { _1.is_a?(String) }
@mode = params.fetch('mode', 'summary')
@axis = params.fetch('axis', 'month')
raise ArgumentError unless ['summary', 'details'].include?(mode) && AXES.include?(axis)
@page = integer('page', 1)
@limit = integer('limit', 20)
raise ArgumentError if limit > 100 || page > 1_000_000
columns = params.fetch('columns', COLUMNS.fetch(resource).join(',')).split(',')
if columns.empty? || columns.uniq != columns || (columns - COLUMNS.fetch(resource)).any?
raise ArgumentError
end
@sort = parse_sort
apply_filters
end
def count = @scope.count
def latest_expression
<<~SQL.squish
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)
end
def summary
expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
"#{ @table }.created_by_user_id"
group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
sql = @scope.group(Arel.sql(expression)).select(Arel.sql(
"#{ expression } AS group_key, COUNT(*) AS record_count, " +
"#{ group_order } AS group_name")).to_sql
connection = @scope.klass.connection
source = "FROM (#{ sql }) counts_groups"
groups = connection.select_value("SELECT COUNT(*) #{ source }")
expressions = { 'group' => ['group_name'], 'count' => ['record_count'] }
order = order_sql(expressions, ['group_key IS NULL ASC', 'group_key ASC'])
rows = connection.select_all(
"SELECT group_key, group_name, record_count #{ source } " +
"ORDER BY #{ order } LIMIT #{ limit } OFFSET #{ (page - 1) * limit }")
{ group_count: groups.to_i,
groups: rows.map do |row|
{ key: row['group_key'],
name: axis == 'user' ? row['group_name'] : nil,
count: row['record_count'].to_i }
end }
end
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)
value = @params[key].presence
return default if value.nil? && !default.nil?
raise ArgumentError unless value&.match?(/\A[1-9][0-9]{0,9}\z/)
value.to_i
end
def timestamp(key)
value = @params[key].presence
return nil unless value
raise ArgumentError unless value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z\z/)
Date.iso8601(value.first(10))
Time.iso8601(value.length == 17 ? value.sub(/Z\z/, ':00Z') : value)
end
def apply_filters
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '1'))
@scope = @scope.where(Arel.sql(latest_expression)) unless @params['latest'] == '0'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
@scope = @scope.where(@table => { @id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
id = integer('tag')
@scope = @scope.where(
"JSON_CONTAINS(post_versions.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED))) OR " +
"JSON_CONTAINS(prev.tags_json, JSON_OBJECT('tag_id', CAST(? AS UNSIGNED)))", id, id)
end
end
+3
ファイルの表示
@@ -1,4 +1,7 @@
Rails.application.routes.draw do
get 'counts/posts', to: 'counts_posts#index'
get 'counts/tags', to: 'counts_tags#index'
resources :locales, only: [:index]
resources :sitemap_entries, only: [:index, :show], param: :resource
+96
ファイルの表示
@@ -0,0 +1,96 @@
require 'rails_helper'
RSpec.describe 'Counts API', type: :request do
let!(:actor) { create(:user, :member) }
let!(:post) { create(:post) }
let!(:tag) { create(:tag, primary_name: 'counts tag', category: :general) }
before do
snapshot = { tag_id: tag.id, version_no: 1, name: 'counts tag',
category: 'general', sections: [] }
[1, 2, 3].each do |number|
time = Time.utc(2024, number == 3 ? 2 : 1, number, 0, 0)
PostVersion.create!(post:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
tags: '', url: post.url, title: "Snapshot #{ number }", parent_post_ids: '',
tags_json: number == 1 ? [snapshot] : [],
created_by_user: number == 3 ? nil : actor, created_at: time)
TagVersion.create!(tag:, version_no: number,
event_type: number == 1 ? 'create' : 'update',
name: 'counts tag', category: 'general', aliases: '', parent_tag_ids: '',
names: { ja: { name: 'counts tag', script_code: 'Jpan',
auto_generated: false, aliases: [] } },
created_by_user: number == 3 ? nil : actor, created_at: time)
end
end
['posts', 'tags'].each do |resource|
context resource do
let(:path) { "/counts/#{ resource }" }
let(:id) { resource == 'posts' ? post.id : tag.id }
let(:filters) { { resource_id: id, user_id: actor.id,
from: '2024-01-01T00:00Z', before: '2024-02-01T00:00Z' } }
it 'uses the same AND filters for SQL totals, groups and details' do
get path, params: filters.merge(axis: 'event_type')
expect(response).to have_http_status(:ok)
summary = response.parsed_body
expect(summary['count']).to eq(2)
expect(summary['groups'].sum { _1['count'] }).to eq(2)
expect(summary['groups'].map { _1['key'] }).to eq(['create', 'update'])
get path, params: filters.merge(mode: 'details')
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
end
it 'paginates details and groups independently from totals' do
get path, params: { resource_id: id, mode: 'details', limit: 1, page: 2 }
expect(response.parsed_body['count']).to eq(3)
expect(response.parsed_body['versions'].sole['version_no']).to eq(2)
get path, params: { resource_id: id, axis: 'month', limit: 1, page: 2 }
expect(response.parsed_body['group_count']).to eq(2)
expect(response.parsed_body['groups'].sole).to include('key' => '2024-02', 'count' => 1)
end
it 'groups null actors and supports their drill-down filter' do
get path, params: { resource_id: id, axis: 'user' }
expect(response.parsed_body['groups']).to include(
{ 'key' => nil, 'name' => nil, 'count' => 1 })
get path, params: { resource_id: id, mode: 'details', user_id: 'none' }
expect(response.parsed_body['count']).to eq(1)
end
it 'uses an inclusive start and exclusive end' do
get path, params: { resource_id: id,
from: '2024-01-01T00:00Z', before: '2024-01-02T00:00Z' }
expect(response.parsed_body['count']).to eq(1)
end
it 'rejects unsupported or malformed query values' do
[
{ axis: 'SQL' }, { limit: 101 }, { page: '1x' }, { user_id: '-1' },
{ event_type: 'invalid' }, { columns: 'password' }, { order: 'id' },
{ from: '2024-02-30T00:00Z' }, { from: '2024-01-01' },
{ user_id: ['1'] }, { mode: 'invalid' }].each do |invalid|
get path, params: invalid
expect(response).to have_http_status(:bad_request)
end
end
it 'retains the existing banned-user boundary' do
actor.update!(banned_at: Time.current)
sign_in_as(actor)
get path
expect(response).to have_http_status(:forbidden)
end
end
end
it 'matches removed tags from the previous snapshot, not current post_tags' do
get '/counts/posts', params: { tag: tag.id, mode: 'details' }
expect(response.parsed_body['count']).to eq(2)
expect(response.parsed_body['versions'].map { _1['version_no'] }).to eq([2, 1])
expect(response.parsed_body['versions'].first['tags'].sole['type']).to eq('removed')
end
end
+17 -4
ファイルの表示
@@ -33,9 +33,9 @@ npm run lint
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
pass or the remaining failure is clearly blocked.
明示的な個別指示がない限り、テストの追加・修正・削除・実行を一切行わない。
fixture、mock、snapshot、テスト補助コード及び既存のテスト差分にも触れない。
ビルド・lint・静的確認を使い、未確認事項はそのまま報告する。
## TypeScript
@@ -128,6 +128,19 @@ pass or the remaining failure is clearly blocked.
## Tailwind and UI
- 原則として、ユーザ向け UI 文言を TSX / TypeScript に直接記述しない。
新しい文言は既存の i18n システムで管理し、適切な既存キーを先に確認する。
不必要な重複定義を避け、新規機能固有の文言は適切な namespace にまとめる。
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
SSR / hydration の整合及び翻訳キーの非露出を検証する。
i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- In JavaScript, JSX, TypeScript, and TSX, use `cn` from `@/lib/utils`
@@ -144,7 +157,7 @@ pass or the remaining failure is clearly blocked.
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
short localised labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- Do not add user-facing copy, helper text, descriptions, notes, tooltips,
+89 -1
ファイルの表示
@@ -62,6 +62,15 @@ const responseFor = url => {
return { locales: [{ code: 'ja', name: '日本語' }, { code: 'ain-Kana', name: 'アイヌ' }] }
if (path === '/users/settings') return { theme: 'system', wiki_editor_mode: 'split' }
if (path === '/users/theme_slots') return []
if (path === '/counts/posts' || path === '/counts/tags') {
const resource = path.endsWith ('posts') ? 'posts' : 'tags'
return { count: 1, page: 1, limit: 20, group_count: 1,
groups: [{ key: url.searchParams.get ('axis') === 'user' ? 1 : '2024-01',
name: null, count: 1 }],
versions: resource === 'posts'
? [{ post_id: 1, version_no: 1, title: { current: `SSR count ${locale}` } }]
: [{ tag_id: 1, version_no: 1, name: { current: `SSR count ${locale}` } }] }
}
if (path === '/posts') return { posts: [post (locale)], count: 1 }
if (path === '/gekanator/posts') return { posts: [post (locale)] }
if (path === '/gekanator/questions') return { questions: [] }
@@ -300,6 +309,25 @@ after (async () => {
if (errors.length) throw new AggregateError (errors, 'SSR teardown failed')
})
test ('counts SSR renders history data and retains URL query state', async () => {
for (const locale of ['ja', 'ain-Kana']) {
for (const resource of ['posts', 'tags']) {
const column = resource === 'posts' ? 'title' : 'name'
const query = `mode=details&columns=${column}&user_id=4`
const result = await renderPage (`/${locale}/counts/${resource}?${query}`, origin, apiURL)
assert.equal (result.status, 200)
assert.match (result.html, new RegExp (`SSR count ${locale}`))
assert.match (result.html, new RegExp (`/${locale}/${resource}/1`))
assert.doesNotMatch (result.html, /counts:|columns\.changedAt|fields\.actor/)
assert.match (result.html, /aria-expanded="false"/)
if (locale === 'ain-Kana')
assert.match (result.html, /lang="ja"[^>]*>集計実行/)
assert.ok (requests.some (request => request.url.includes (`/counts/${resource}`)
&& request.url.includes ('user_id=4') && request.url.includes (`locale=${locale}`)))
}
}
})
test ('every canonical screen route returns page HTML without JavaScript', async t => {
const cookie = await authenticatedCookie ()
for (const route of routes) {
@@ -582,7 +610,8 @@ test ('hydrate every route with stored browser preferences', {
}` }, sessionId)
const evaluate = async expression => (await send ('Runtime.evaluate', {
expression, returnByValue: true }, sessionId)).result.value
for (const route of routes.filter (entry => !entry.handle?.redirectTo)) {
for (const route of routes.filter (entry => !entry.handle?.redirectTo
&& (process.env.SSR_COUNTS_ONLY !== '1' || entry.path.startsWith ('counts')))) {
for (const locale of ['ja', 'ain-Kana']) {
await t.test (`hydrate ${locale} ${route.path}`, async () => {
const path = route.path.replace (':identifier', 'home').replace (/:id|:title/g, '1')
@@ -608,6 +637,65 @@ test ('hydrate every route with stored browser preferences', {
})
}
}
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
width, height: 900, deviceScaleFactor: 1, mobile: width < 768 }, sessionId)
for (const resource of ['posts', 'tags']) {
await t.test (`counts layout ${width} ${resource}`, async () => {
await send ('Page.navigate', {
url: `${httpOrigin}/ja/counts/${resource}` }, sessionId)
let ready = false
for (let attempt = 0; attempt < 100; ++attempt) {
await new Promise (resolve => setTimeout (resolve, 75))
try {
ready = await evaluate (`document.querySelector('tbody tr')!=null`)
} catch {}
if (ready) break
}
assert.ok (ready, 'history query results must render')
const layout = await evaluate (`(()=>{
const nav=document.getElementById('counts-targets');
const main=document.querySelector('main').getBoundingClientRect();
return {visible:nav.getClientRects().length>0,
right:nav.getBoundingClientRect().right,left:main.left,width:main.width};
})()`)
assert.ok (width >= 768 ? layout.visible && layout.right <= layout.left
: !layout.visible && layout.width >= width - 16,
'the sidebar sits beside the table on desktop and collapses on mobile')
const toggle = `Array.from(document.querySelectorAll('button'))
.find(button=>button.textContent.includes('集計条件'))`
assert.equal (await evaluate (`(${toggle}).getAttribute('aria-expanded')`), 'false')
await evaluate (`(${toggle}).click()`)
const positions = await evaluate (`(()=>{
const output=document.querySelector('[data-counts-output]').getBoundingClientRect();
const filters=document.querySelector('[data-counts-filters]').getBoundingClientRect();
return {left:output.left,right:filters.left,top:output.top,bottom:filters.top};
})()`)
assert.ok (width >= 768 ? positions.left < positions.right
: positions.top < positions.bottom, 'output precedes filters in the layout')
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'))
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-conditions.png`,
Buffer.from (shot.data, 'base64'))
}
await evaluate (`(${toggle}).click()`)
for (const dark of [false, true]) {
await evaluate (`document.documentElement.classList.toggle('dark',${dark})`)
assert.ok (await evaluate ('document.documentElement.scrollWidth<=innerWidth'),
'counts controls must fit the viewport in both themes')
if (resource === 'posts' && process.env.SSR_COUNTS_ONLY === '1') {
const { writeFile } = await import ('node:fs/promises')
const shot = await send ('Page.captureScreenshot', { format: 'png' }, sessionId)
await writeFile (`/tmp/counts-${width}-${dark ? 'dark' : 'light'}.png`,
Buffer.from (shot.data, 'base64'))
}
}
})
}
}
if (process.env.SSR_COUNTS_ONLY === '1') return
probeActive = true
for (const width of [375, 1240]) {
await send ('Emulation.setDeviceMetricsOverride', {
+8
ファイルの表示
@@ -77,6 +77,8 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
const NicoTagListPage = lazyPage (() => import ('@/pages/tags/NicoTagListPage'))
@@ -194,6 +196,12 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsPage/>,
handle: { preload: CountsPage.preload } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'more', element: <MorePage/>,
handle: { preload: MorePage.preload, sitemap: ['static'] } },
{ path: '*', element: <NotFound/>, handle: { notFound: true } }]
+88
ファイルの表示
@@ -0,0 +1,88 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useUITranslation } from '@/lib/useUITranslation'
export type CountsMenuAction = 'copy' | 'copyHeaders' | 'row' | 'column' | 'all'
const CountsCellMenu = (
{ x, y, act, close }: {
x: number
y: number
act: (action: CountsMenuAction) => void
close: () => void },
) => {
const root = useRef<HTMLDivElement> (null)
const { label, message } = useUITranslation ('counts')
useLayoutEffect (() => {
const menu = root.current
if (menu == null) return
const rect = menu.getBoundingClientRect ()
menu.style.left = `${Math.max (4, Math.min (x, window.innerWidth - rect.width - 4))}px`
menu.style.top = `${Math.max (4, Math.min (y, window.innerHeight - rect.height - 4))}px`
menu.querySelector ('button')?.focus ({ preventScroll: true })
}, [x, y])
useEffect (() => {
const outside = (event: Event) => {
if (!root.current?.contains (event.target as Node)) close ()
}
const escape = (event: KeyboardEvent) => {
if (event.key === 'Escape')
{
event.preventDefault ()
event.stopPropagation ()
close ()
}
}
document.addEventListener ('pointerdown', outside, true)
document.addEventListener ('keydown', escape, true)
window.addEventListener ('resize', close)
document.addEventListener ('scroll', outside, true)
return () => {
document.removeEventListener ('pointerdown', outside, true)
document.removeEventListener ('keydown', escape, true)
window.removeEventListener ('resize', close)
document.removeEventListener ('scroll', outside, true)
}
}, [close])
const actions: CountsMenuAction[] = ['copy', 'copyHeaders', 'row', 'column', 'all']
return createPortal (
<div ref={root} role="menu" aria-label={message ('selection.table').text}
className="fixed z-50 min-w-40 rounded border border-slate-400 bg-white
py-1 text-sm text-slate-900 shadow-lg dark:border-slate-600
dark:bg-slate-800 dark:text-slate-100"
style={{ left: x, top: y }} onContextMenu={event => event.preventDefault ()}
onPointerDown={event => event.stopPropagation ()}
onKeyDown={event => {
event.stopPropagation ()
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase () === 'c')
{
event.preventDefault ()
act ('copy')
return
}
const buttons = Array.from (root.current?.querySelectorAll ('button') ?? [])
const current = buttons.indexOf (document.activeElement as HTMLButtonElement)
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes (event.key))
{
event.preventDefault ()
const next = event.key === 'Home' ? 0 : (event.key === 'End' ? buttons.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length)
% buttons.length)
buttons[next]?.focus ()
}
if (event.key === 'Tab') close ()
}}>
{actions.map (action => (
<button key={action} type="button" role="menuitem"
className="block w-full px-3 py-1 text-left hover:bg-slate-200
hover:text-slate-900 focus:bg-slate-200 focus:text-slate-900
dark:hover:bg-slate-700 dark:hover:text-slate-100
dark:focus:bg-slate-700 dark:focus:text-slate-100"
onClick={() => act (action)}>{label (`selection.${action}`)}</button>))}
</div>, document.body)
}
export default CountsCellMenu
+171
ファイルの表示
@@ -0,0 +1,171 @@
import { useEffect, 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, displayJSON, reorder }: {
resource: CountsResource
applied: URLSearchParams
open: boolean
setOpen: (open: boolean) => void
execute: (params: URLSearchParams) => void
displayJSON: (enabled: boolean) => void
reorder: (value: string) => void },
) => {
const { label, message } = useUITranslation ('counts')
const locale = useLocale ()
const id = useId ()
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
const order = applied.get ('order')
const nestedJSON = applied.get ('nested_json')
useEffect (() => {
setDraft (previous => {
const next = new URLSearchParams (previous)
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
{
if (value == null) next.delete (key)
else next.set (key, value)
}
return next
})
}, [order, nestedJSON])
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>
<label className="mb-2 flex items-center gap-1">
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
onChange={event => {
update ('nested_json', event.target.checked ? '1' : '0')
displayJSON (event.target.checked)
}}/>
{label ('nested.asJSON')}
</label>
{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={(key, value) => {
update (key, value)
if (key === 'order') reorder (value)
}}/>)}
</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') !== '0'}
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
+149
ファイルの表示
@@ -0,0 +1,149 @@
import { DndContext, MouseSensor, TouchSensor, closestCenter,
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
import { countChildPaths, fieldsForCounts, orderedCountFields, reorderCountFields,
selectedCountChildren, selectedCountColumns } from '@/lib/counts'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountField, CountsResource } from '@/lib/counts'
type BranchProps = {
field: CountField
path: string
enabled: boolean
selected: Set<string>
params: URLSearchParams
select: (path: string, checked: boolean) => void
update: (key: string, value: string) => void
move: (from: string, to: string) => void
previous?: string
next?: string }
const FieldBranch = (
{ field, path, enabled, selected, params, select, update, move, previous, next }: BranchProps,
) => {
const { label, message } = useUITranslation ('counts')
const fixed = path === 'thumbnail'
const drag = useDraggable ({ id: field.key, disabled: fixed })
const drop = useDroppable ({ id: field.key, disabled: fixed })
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 (
<div ref={drop.setNodeRef} className={cn ('flex items-start gap-1',
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
<div ref={drag.setNodeRef} className="flex min-w-0 flex-1 items-start gap-1"
style={drag.transform == null ? undefined : {
position: 'relative', zIndex: 1,
transform: `translate3d(${drag.transform.x}px, ${drag.transform.y}px, 0)` }}>
{fixed ? <span className="w-6 shrink-0"/> : (
<button type="button" ref={drag.setActivatorNodeRef}
className="mt-1 w-6 shrink-0 cursor-grab touch-none active:cursor-grabbing"
{...drag.attributes} {...drag.listeners}
aria-keyshortcuts="ArrowUp ArrowDown"
aria-label={message ('reorder', { column: message (field.label).text }).text}
lang={message ('reorder').language}
onClick={event => event.stopPropagation ()}
onKeyDown={event => {
if (!['ArrowUp', 'ArrowDown'].includes (event.key)) return
event.preventDefault ()
event.stopPropagation ()
const target = event.key === 'ArrowUp' ? previous : next
if (target != null) move (field.key, target)
}}><span aria-hidden>⠿</span></button>)}
{field.children ? (
<details className="my-1 min-w-0 flex-1">
<summary className="cursor-pointer">{control}</summary>
<div className="ml-4 border-l pl-2 dark:border-slate-700">
<FieldGroup fields={field.children} path={path} params={params}
enabled={enabled && checked} selected={selected} select={select}
update={update}/>
</div>
</details>) : <div className="my-1">{control}</div>}
</div>
</div>)
}
const FieldGroup = (
{ fields, path, params, enabled, selected, select, update }: {
fields: CountField[]
path: string
params: URLSearchParams
enabled: boolean
selected: Set<string>
select: BranchProps['select']
update: BranchProps['update'] },
) => {
const { message } = useUITranslation ('counts')
const ordered = orderedCountFields (fields, params, path)
const movable = ordered.filter (field => path !== '' || field.key !== 'thumbnail')
const sensors = useSensors (
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
const move = (from: string, to: string) =>
update ('order', reorderCountFields (fields, params, path, from, to))
return (
<DndContext sensors={sensors} collisionDetection={closestCenter}
accessibility={{ announcements: {
onDragStart: () => undefined, onDragOver: () => undefined,
onDragMove: () => undefined,
onDragEnd: () => undefined, onDragCancel: () => undefined },
screenReaderInstructions: { draggable: message ('output').text } }}
onDragEnd={({ active, over }) => {
if (over != null && active.id !== over.id) move (String (active.id), String (over.id))
}}>
{ordered.map (field => {
const position = movable.findIndex (item => item.key === field.key)
return <FieldBranch key={field.key} field={field}
path={path ? `${path}.${field.key}` : field.key}
enabled={enabled} selected={selected} params={params}
select={select} update={update} move={move}
previous={movable[position - 1]?.key} next={movable[position + 1]?.key}/>
})}
</DndContext>)
}
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 (
<>
<FieldGroup fields={fields} path="" params={params} enabled selected={selected}
select={select} update={update}/>
<p className="mt-2 text-xs">{label ('nested.selection')}</p>
</>)
}
export default CountsFieldTree
+107
ファイルの表示
@@ -0,0 +1,107 @@
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, orderedCountFields } from '@/lib/counts'
import { countsLogicalName, countsNestedExport,
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 = orderedCountFields (fields, params, path)
.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 (
<div className="counts-nested w-full min-w-0 max-w-full">
{params.get ('nested_json') === '1' ? (
<pre data-counts-scroll
aria-label={title.text} lang={title.language}
className="max-h-72 w-full overflow-auto whitespace-pre-wrap
break-words text-xs">
{JSON.stringify (countsNestedExport (rows, fields, path, params, locale), null, 2)}
</pre>) : (columns.length > 0 && rows.length > 0 && (
<div data-counts-scroll
className="relative isolate 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="text-xs">
<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>))}
</div>)
}
export default CountsNestedTable
+93
ファイルの表示
@@ -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
+167
ファイルの表示
@@ -0,0 +1,167 @@
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, countsRequestParams, fieldsForCounts,
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
import useDialogue from '@/lib/dialogues/useDialogue'
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 dialogue = useDialogue ()
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 : (
<button type="button" className="block" aria-label={message (
'shared:labels.thumbnail').text}
onClick={event => {
const trigger = event.currentTarget
void dialogue.form ({
title: label ('shared:labels.thumbnail'),
cancelText: label ('shared:dialogue.close'),
size: 'viewport',
body: () => <img src={url} alt={message ('shared:labels.thumbnail').text}
referrerPolicy="no-referrer" className="h-full w-full object-contain"/>
}).then (() => trigger.focus ({ preventScroll: true }))
}}>
<PostThumbnailPreview url={url} className="h-10 w-16"
alt={message ('shared:labels.thumbnail').text} referrerPolicy="no-referrer"/>
</button>)
}
case 'url':
case 'thumbnail_base':
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
{value}</a>
case 'is_latest':
return <LocalisedText language={message ('latestValue').language}>
{value}</LocalisedText>
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}:${countsRequestParams (params)}:${columns.join (',')}:${
(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} data-counts-heading={message (countColumns[column]).text}
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
+306
ファイルの表示
@@ -0,0 +1,306 @@
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
import CountsCellMenu from '@/components/counts/CountsCellMenu'
import { countsClipboard, writeCountsClipboard } from '@/lib/countsClipboard'
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { ClipboardEvent, ReactNode } from 'react'
import type { CountsMenuAction } from '@/components/counts/CountsCellMenu'
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 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 [menu, setMenu] = useState<{ x: number; y: number; point: Point
identity: string } | null> (null)
const suppressClick = useRef (false)
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 closeMenu = useCallback (() => {
setMenu (null)
root.current?.focus ({ preventScroll: true })
}, [])
const openMenu = (point: Point, x: number, y: number) => {
const inside = bounds != null && point.row >= bounds.top && point.row <= bounds.bottom
&& point.column >= bounds.left && point.column <= bounds.right
if (inside) setStored (selection)
else setStored ({ identity, anchor: point, end: point })
setMenu ({ point, x, y, identity })
}
const clipboard = (headers = false) => {
if (bounds == null || root.current == null) return null
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)
}
if (headers)
{
const heading = root.current.tHead?.rows[0]
rows.unshift (Array.from ({ length: bounds.right - bounds.left + 1 }, (entry, index) => {
void entry
const cell = heading?.cells[bounds.left + index]
return cell?.dataset.countsHeading ?? cell?.textContent ?? ''
}))
}
return countsClipboard (rows, headers)
}
const menuAction = (action: CountsMenuAction) => {
if (menu == null || root.current == null) return
if (action === 'copy' || action === 'copyHeaders')
{
const payload = clipboard (action === 'copyHeaders')
if (payload != null) void writeCountsClipboard (payload).catch (error => {
console.error (error)
})
}
else
{
const lastRow = (root.current.tBodies[0]?.rows.length ?? 1) - 1
const lastColumn = columns.length - 1
const anchor = {
row: action === 'row' ? menu.point.row : 0,
column: action === 'column' ? menu.point.column : 0 }
const end = {
row: action === 'row' ? menu.point.row : lastRow,
column: action === 'column' ? menu.point.column : lastColumn }
setStored ({ identity, anchor, end })
}
closeMenu ()
}
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
if (document.activeElement !== root.current) return
const payload = clipboard ()
if (payload == null) return
event.preventDefault ()
event.stopPropagation ()
event.clipboardData.setData ('text/plain', payload.text)
try
{
event.clipboardData.setData ('text/html', payload.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 ('counts-grid 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}
onClickCapture={event => {
if ((event.target as Element).closest ('table') !== event.currentTarget
|| !suppressClick.current) return
event.preventDefault ()
event.stopPropagation ()
suppressClick.current = false
}}
onPointerDown={event => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
suppressClick.current = false
if (element.closest (interactive) != null || event.button !== 0) return
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
{
const rect = element.getBoundingClientRect ()
if (event.clientX >= rect.left + element.clientLeft + element.clientWidth
|| event.clientY >= rect.top + element.clientTop + element.clientHeight) return
}
const point = pointFor (element)
if (point == null) return
event.stopPropagation ()
setMenu (null)
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 (() => {
suppressClick.current = true
openMenu (point, event.clientX, event.clientY)
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
if (touchArmed.current && touchPress.current != null
&& Math.hypot (event.clientX - touchPress.current.x,
event.clientY - touchPress.current.y) <= 8) return
setMenu (null)
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 => {
const element = event.target as Element
if (element.closest ('table') !== event.currentTarget) return
const point = pointFor (element)
if (point == null || element.closest ('[data-counts-resize]') != null) return
event.preventDefault ()
event.stopPropagation ()
openMenu (point, event.clientX, event.clientY)
}}
onKeyDown={event => {
if (event.target !== event.currentTarget) return
if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10'))
{
const point = selection?.end ?? { row: 0, column: 0 }
const rect = cellAt (event.currentTarget, point)?.getBoundingClientRect ()
if (rect != null)
{
event.preventDefault ()
openMenu (point, rect.left, rect.bottom)
}
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>
{menu?.identity === identity && <CountsCellMenu x={menu.x} y={menu.y}
act={menuAction} close={closeMenu}/>}
</CountsTableScope.Provider>)
}
export default CountsSelectableTable
+97
ファイルの表示
@@ -0,0 +1,97 @@
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 data-counts-heading={label.text}
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
+24
ファイルの表示
@@ -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
+45
ファイルの表示
@@ -0,0 +1,45 @@
.counts-grid > tbody > tr > td {
@apply h-6;
}
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-white text-slate-900;
}
.counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-50 text-slate-900;
}
.dark .counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
@apply bg-slate-950 text-slate-100;
}
.dark .counts-grid > tbody > tr:nth-child(even) > td:not([data-counts-selected]) {
@apply bg-slate-900 text-slate-100;
}
.counts-grid > thead {
@apply bg-slate-200 text-slate-950;
}
.counts-grid > thead > tr > th {
@apply border-b border-r border-slate-300 bg-slate-200 text-slate-950;
}
.dark .counts-grid > thead,
.dark .counts-grid > thead > tr > th {
@apply border-slate-600 bg-slate-800 text-slate-50;
}
.counts-grid > tbody > tr > td {
@apply border-r border-slate-200 dark:border-slate-700;
-webkit-touch-callout: none;
}
.counts-grid > tbody > tr > td:has(> .counts-nested) {
padding: 0;
}
.counts-nested {
@apply border-l-2 border-slate-300 dark:border-slate-600;
}
+7 -7
ファイルの表示
@@ -8,6 +8,7 @@ import { Dialog,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext } from '@/lib/dialogues/useDialogue'
import { cn } from '@/lib/utils'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, ReactNode } from 'react'
@@ -183,13 +184,12 @@ const DialogueProvider: FC<Props> = ({ children }) => {
<DialogContent
closeLabel={(active.kind === 'confirm' || active.kind === 'choice')
? active.options.closeLabel : undefined}
className={
active.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
className={cn (active.kind === 'form'
? 'flex max-h-[calc(100dvh-1rem)] flex-col overflow-hidden gap-0'
: 'px-6 pb-6 pt-7', active.kind === 'form' && (
active.options.size === 'viewport'
? 'h-[88dvh] w-[88vw] max-w-none'
: (active.options.size === 'large' ? 'max-w-3xl' : 'max-w-lg')))}
onEscapeKeyDown={event => {
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
event.preventDefault ()
+1
ファイルの表示
@@ -1,4 +1,5 @@
@import "@fontsource-variable/noto-sans-jp";
@import './components/counts/countsTable.css';
@tailwind base;
@tailwind components;
+41
ファイルの表示
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, normaliseCountsParams } from '@/lib/counts'
import { createUII18n, translateUIMessage } from '@/lib/i18n'
describe ('counts exports', () => {
it ('resolves every output and CSV header key in both locales', () => {
for (const locale of ['ja', 'ain-Kana'])
{
const instance = createUII18n (locale)
for (const key of Object.values (countColumns))
{
const translated = translateUIMessage (key, 'counts', locale, { }, instance)
expect (translated.text).not.toBe (key)
expect (translated.text).not.toContain ('counts:')
}
const fallback = translateUIMessage ('run', 'counts', locale, { }, instance)
expect (fallback).toEqual ({ text: '集計実行', language: 'ja' })
}
})
it ('makes the monthly default explicit for the existing API', () => {
expect (normaliseCountsParams ('posts', new URLSearchParams ()).get ('axis'))
.toBe ('month')
})
it ('escapes quotes, newlines and spreadsheet formulas in UTF-8 CSV', () => {
expect (countsCSV ([['日本語', 'a"b', 'a\nb', '=1+1']]))
.toBe ('\uFEFF"日本語","a""b","a\nb","\'=1+1"')
})
it ('keeps resource-specific columns out of the other resource', () => {
expect (columnsForCounts ('posts')).toContain ('added_tags')
expect (columnsForCounts ('posts')).not.toContain ('aliases')
expect (columnsForCounts ('tags')).toContain ('aliases')
expect (columnsForCounts ('tags')).not.toContain ('url')
expect (columnsForCounts ('posts')).not.toContain ('event_type')
expect (columnsForCounts ('tags')).not.toContain ('event_type')
})
})
+285
ファイルの表示
@@ -0,0 +1,285 @@
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
import type { TagNameSnapshot } from '@/types'
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 = {
count: number
page: number
limit: number
groupCount?: number
groups?: { key: string | number | null; name: string | null; count: number }[]
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 = {
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
version_no: 'history:version', is_latest: 'counts:columns.latest',
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
original_created_at: 'shared:labels.originalCreatedAt',
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
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' },
{ 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[] => [
...(resource === 'posts' ? ['thumbnail'] as const : []),
'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
'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 orderedCountFields = (
fields: CountField[],
params: URLSearchParams,
parent = '',
): CountField[] => {
const prefix = parent ? `${parent}.` : ''
const saved = (params.get ('order') ?? '').split (',')
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
const ordered = keys.flatMap (key => {
const field = fields.find (field => `${prefix}${field.key}` === key)
return field == null ? [] : [field]
})
return parent ? ordered : [
...ordered.filter (field => field.key === 'thumbnail'),
...ordered.filter (field => field.key !== 'thumbnail')]
}
export const reorderCountFields = (
fields: CountField[],
params: URLSearchParams,
parent: string,
from: string,
to: string,
): string => {
const ordered = orderedCountFields (fields, params, parent)
const first = ordered.findIndex (field => field.key === from)
const last = ordered.findIndex (field => field.key === to)
if (first < 0 || last < 0) return params.get ('order') ?? ''
const prefix = parent ? `${parent}.` : ''
const current = ordered.map (field => `${prefix}${field.key}`)
const next = [...current]
next.splice (last, 0, ...next.splice (first, 1))
return [...(params.get ('order') ?? '').split (',').filter (
key => key && !current.includes (key)), ...next].join (',')
}
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 orderedCountFields (fieldsForCounts (resource), params)
.map (field => field.key as CountColumn).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 = (
resource: CountsResource,
source: URLSearchParams,
): URLSearchParams => {
const params = new URLSearchParams (source)
params.delete ('event_type')
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
params.set ('axis', 'month')
if (params.has ('columns'))
{
const selected = new Set (params.get ('columns')!.split (','))
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
.join (','))
}
return params
}
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
const result = new URLSearchParams (params)
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order'])
result.delete (key)
return result
}
export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise<CountsResult> => apiGet (`/counts/${resource}`, {
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 =>
'\uFEFF' + rows.map (row => row.map (value => {
const safe = /^[=+@\-\t\r]/.test (value) ? `'${value}` : value
return `"${safe.replaceAll ('"', '""')}"`
}).join (',')).join ('\r\n')
+70
ファイルの表示
@@ -0,0 +1,70 @@
export type CountsClipboard = { text: string; html: string }
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!)
export const countsClipboard = (rows: string[][], headers = false): CountsClipboard => {
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
// Prefix formula-like text in both representations, including leading whitespace.
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
? `'${value}` : value))
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
const tag = headers && index === 0 ? 'th' : 'td'
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value)
.replace (/\r\n|\r|\n/g, '<br>')}</${tag}>`
}).join ('') + '</tr>').join ('') + '</table></body></html>'
return { text, html }
}
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined')
{
try
{
await navigator.clipboard.write ([new ClipboardItem ({
'text/plain': new Blob ([payload.text], { type: 'text/plain' }),
'text/html': new Blob ([payload.html], { type: 'text/html' }) })])
return
}
catch
{
// Browsers without HTML clipboard support may still permit plain TSV.
}
}
if (navigator.clipboard?.writeText != null)
{
try
{
await navigator.clipboard.writeText (payload.text)
return
}
catch
{
// Try the synchronous copy event when clipboard permission is unavailable.
}
}
// The synchronous copy event fallback is also used on non-secure local origins.
const listener = (event: ClipboardEvent) => {
event.preventDefault ()
event.stopImmediatePropagation ()
event.clipboardData?.setData ('text/plain', payload.text)
event.clipboardData?.setData ('text/html', payload.html)
}
document.addEventListener ('copy', listener, true)
try
{
if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable')
}
finally
{
document.removeEventListener ('copy', listener, true)
}
}
+214
ファイルの表示
@@ -0,0 +1,214 @@
import { fieldsForCounts, orderedCountFields, 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 row.isLatest ? translateUIMessage ('latestValue', '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 countsSectionRanges = (value: unknown, locale: LocaleCode): string[] | null => {
if (!Array.isArray (value)) return null
const time = (ms: number): string => {
const parts = msToTime (ms).split (':')
if (parts.length === 2) parts[0] = parts[0].padStart (2, '0')
return parts.join (':')
}
return value.map ((section: { beginMs: number; endMs: number | null }) =>
translateUIMessage ('sections.range', 'counts', locale, {
begin: time (section.beginMs),
end: section.endMs == null ? '' : time (section.endMs) }).text)
}
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: '' }
if (field === 'sections')
return { text: (countsSectionRanges (value, locale) ?? []).join (
translateUIMessage ('sections.separator', 'counts', locale).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)
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 = orderedCountFields (fields, params, path)
.filter (field => selected.has (`${path}.${field.key}`))
if (columns.length === 0) return rows.map (() => ({ }))
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)
: (field.key === 'sections'
? countsSectionRanges (row[field.key], 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 = orderedCountFields (fields, params, path)
.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 => {
if (field.children == null) return countsCellText (row, field.key as CountColumn, locale)
const rows = row[field.key as keyof CountsVersion] as CountRecord[] ?? []
return params.get ('nested_json') === '1'
? JSON.stringify (
countsNestedExport (rows, field.children, field.key, params, locale), null, 2)
: countsNestedText (rows, field.children, field.key, params, 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
}
}
+28
ファイルの表示
@@ -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: 260,
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
}
+1 -1
ファイルの表示
@@ -41,7 +41,7 @@ type DialogueFormOptions = { title: ReactNode
description?: ReactNode
body: (controls: DialogueFormControls) => ReactNode
cancelText?: ReactNode
size?: 'default' | 'large' }
size?: 'default' | 'large' | 'viewport' }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
+4
ファイルの表示
@@ -161,3 +161,7 @@ export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
['theatres', locale, id, 'programmes'] as const }
export const countsKeys = {
index: (resource: string, params: string, locale: LocaleCode) =>
['counts', resource, locale, params] as const }
+26 -26
ファイルの表示
@@ -91,36 +91,36 @@ export const originalCreatedAtString = (
const unknownLabel = (key: string) => i18n.t (`history:${key}`, { lng: locale })
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 (diff <= 60_000 /* 1 分 */)
return format (from, 'second')
if (from.getMinutes () === 0 && before.getMinutes () === 0)
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (fromParts.getUTCHours () === 0 && beforeParts.getUTCHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
if (fromParts.getUTCDate () === 1 && beforeParts.getUTCDate () === 1)
{
if (Math.abs (diff - 3_600_000 /* 1 時間 */) < 60_000)
return `${format (from, 'minute')}${space}(${unknownLabel ('unknownMinute')})`
if (from.getHours () === 0 && before.getHours () === 0)
{
if (Math.abs (diff - 86_400_000 /* 1 日 */) < 60_000)
return `${format (from, 'hour')}${space}(${unknownLabel ('unknownTime')})`
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')})`
}
}
if (2_332_800_000 /* 27 日 */ < diff && diff < 2_764_800_000 /* 32 日 */)
return `${format (from, 'day')}${space}(${unknownLabel ('unknownDay')})`
if (fromParts.getUTCMonth () === 0 && beforeParts.getUTCMonth () === 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') : '',
'〜',
+3
ファイルの表示
@@ -0,0 +1,3 @@
{
"_untranslated": ["*"]
}
+93
ファイルの表示
@@ -0,0 +1,93 @@
{
"titles": {
"posts": "投稿の集計・明細",
"tags": "タグの集計・明細"
},
"target": "集計対象",
"run": "集計実行",
"conditions": "集計条件",
"output": "出力項目",
"filters": "抽出条件",
"mode": "表示",
"summary": "集計",
"details": "明細",
"axis": "集計軸",
"axes": {
"month": "月別(UTC)",
"user": "$t(history:actor)"
},
"metric": "履歴レコード件数",
"total": "履歴レコード件数:{{count}}",
"csv": "CSV",
"csvScope": "表示中のページ・選択列を CSV 出力",
"empty": "該当する履歴はありません。",
"columns": {
"changedAt": "操作日時",
"resourceId": "Id.",
"postTitle": "投稿タイトル",
"postURL": "投稿 URL",
"tags": "記載タグ",
"deprecated": "廃止状態",
"latest": "最新フラグ",
"thumbnailURL": "サムネール元 URL",
"originalFrom": "オリジナルの投稿日時(開始境界)",
"originalBefore": "オリジナルの投稿日時(終了境界・未満)",
"language": "言語",
"kind": "内部・外部",
"sections": "記載区間",
"names": "言語別名称",
"thumbnailDisplay": "サムネール表示"
},
"fields": {
"from": "開始(端末の時刻帯・以上)",
"before": "終了(端末の時刻帯・未満)",
"post": "投稿 ID",
"tag": "タグ名または ID",
"actor": "操作者 ID",
"limit": "表示件数"
},
"latestOnly": "最新のみ",
"boolean": {
"true": "はい",
"false": "いいえ"
},
"kinds": {
"internal": "内部タグ",
"external": "外部タグ"
},
"nested": {
"toggle": "表内表を展開・折り畳み",
"selection": "親を OFF にしても子項目の選択は保持します。",
"noColumns": "出力項目が選択されていません。",
"asJSON": "内表を JSON 表示"
},
"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 で解除。",
"copy": "コピー",
"copyHeaders": "見出し付きコピー",
"row": "行を選択",
"column": "列を選択",
"all": "すべて選択"
},
"resize": {
"column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
},
"latestValue": "最新",
"sections": {
"range": "{{begin}}~{{end}}",
"separator": "、"
},
"reorder": "{{column}}:出力項目の並べ替え"
}
+6
ファイルの表示
@@ -0,0 +1,6 @@
import CountsResourcePage from '@/pages/CountsResourcePage'
const CountsPage = () => <CountsResourcePage resource="posts"/>
export default CountsPage
+157
ファイルの表示
@@ -0,0 +1,157 @@
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import CountsPage from '@/pages/CountsPage'
import CountsResourcePage from '@/pages/CountsResourcePage'
import { renderWithProviders } from '@/test/render'
import type { CountsResult } from '@/lib/counts'
const { fetchCounts, fetchTagByName } = vi.hoisted (() => ({
fetchCounts: vi.fn (), fetchTagByName: vi.fn () }))
vi.mock ('@/lib/counts', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/counts')> (), fetchCounts }))
vi.mock ('@/lib/tags', async importOriginal => ({
...await importOriginal<typeof import ('@/lib/tags')> (), fetchTagByName }))
beforeEach (() => {
fetchCounts.mockReset ()
fetchTagByName.mockReset ()
fetchCounts.mockResolvedValue ({ count: 0, groupCount: 0, groups: [], page: 1, limit: 20 })
})
const conditions = () => screen.getByRole ('button', { name: '集計条件' })
const execute = () => fireEvent.submit (
screen.getByRole ('button', { name: '集計実行' }).closest ('form')!)
describe ('counts workspace', () => {
it ('opens the posts workspace at the root with a resource sidebar', async () => {
renderWithProviders (<CountsPage/>, { route: '/ja/counts' })
fireEvent.click (screen.getByRole ('button', { name: '集計対象' }))
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('href', '/ja/counts/posts')
expect (screen.getByRole ('link', { name: '投稿' }))
.toHaveAttribute ('aria-current', 'page')
expect (screen.getByRole ('link', { name: 'タグ' }))
.toHaveAttribute ('href', '/ja/counts/tags')
expect (conditions ()).toHaveAttribute ('aria-expanded', 'false')
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('axis')).toBe ('month'))
})
it ('preserves draft filters on collapse without querying until execution', async () => {
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?page=3&mode=details' })
await screen.findByText ('履歴レコード件数:0')
const calls = fetchCounts.mock.calls.length
fireEvent.click (conditions ())
const output = document.querySelector ('[data-counts-output]')!
const filters = document.querySelector ('[data-counts-filters]')!
expect (output.compareDocumentPosition (filters) & Node.DOCUMENT_POSITION_FOLLOWING)
.toBeTruthy ()
fireEvent.change (screen.getByRole ('textbox', { name: '投稿 ID' }), {
target: { value: '7' } })
fireEvent.change (screen.getByRole ('textbox', { name: '操作者 ID' }), {
target: { value: '9' } })
fireEvent.click (conditions ())
expect (screen.getByRole ('table')).toBeInTheDocument ()
fireEvent.click (conditions ())
expect (screen.getByRole ('textbox', { name: '投稿 ID' })).toHaveValue ('7')
expect (fetchCounts).toHaveBeenCalledTimes (calls)
execute ()
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('resource_id')).toBe ('7')
expect (params.get ('user_id')).toBe ('9')
expect (params.has ('page')).toBe (false)
})
const refreshed = fetchCounts.mock.calls.length
execute ()
await waitFor (() => expect (fetchCounts.mock.calls.length).toBeGreaterThan (refreshed))
})
it ('applies selected columns only on execution and retains pagination state', async () => {
fetchCounts.mockResolvedValue ({ count: 21, page: 1, limit: 20,
versions: [{ postId: 7, versionNo: 2, url: { current: 'https://example.com' },
title: { current: 'Snapshot' } }] as CountsResult['versions'] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3' })
expect (await screen.findByRole ('cell', { name: 'Snapshot' })).toBeInTheDocument ()
expect (screen.getByLabelText ('次のページ')).toHaveAttribute (
'href', '/ja/counts/posts?axis=month&mode=details&columns=title&user_id=3&page=2')
fireEvent.click (conditions ())
expect (screen.getByRole ('checkbox', { name: '投稿タイトル' })).toBeChecked ()
fireEvent.click (screen.getByRole ('checkbox', { name: '投稿 URL' }))
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title')
execute ()
await waitFor (() => {
expect (fetchCounts.mock.lastCall?.[1].get ('columns')).toBe ('title,url')
})
})
it ('removes legacy event fields and axes from public query state', async () => {
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=event_type&event_type=update&columns=event_type,name' })
await waitFor (() => {
const params = fetchCounts.mock.lastCall?.[1] as URLSearchParams
expect (params.get ('axis')).toBe ('month')
expect (params.has ('event_type')).toBe (false)
expect (params.get ('columns')).toBe ('name')
})
fireEvent.click (conditions ())
expect (screen.queryByText ('操作種別')).not.toBeInTheDocument ()
expect (screen.getByRole ('combobox', { name: '集計軸' })).toHaveValue ('month')
})
it ('resolves a tag name with the existing lookup before running the query', async () => {
fetchTagByName.mockResolvedValue ({ id: 12 })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts' })
fireEvent.click (conditions ())
fireEvent.change (screen.getByRole ('textbox', { name: 'タグ名または ID' }), {
target: { value: '日本語タグ' } })
execute ()
await waitFor (() => expect (fetchCounts.mock.lastCall?.[1].get ('tag')).toBe ('12'))
expect (fetchTagByName).toHaveBeenCalledWith ('日本語タグ', 'ja')
})
it ('uses Japanese fallback metadata in the Ainu workspace without exposing keys', async () => {
const { container } = renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ain-Kana/counts/tags' })
const title = screen.getByRole ('heading', { name: 'タグの集計・明細' })
expect (title.querySelector ('[lang="ja"]')).toHaveTextContent ('タグの集計・明細')
expect (conditions ().querySelector ('[lang="ja"]')).toBeInTheDocument ()
fireEvent.click (conditions ())
await screen.findByRole ('table')
expect (container.textContent).not.toMatch (/counts:|columns\.|fields\.|axes\./)
})
it ('drills through an actor group while preserving the existing filters', async () => {
fetchCounts.mockResolvedValue ({ count: 3, groupCount: 1, page: 1, limit: 20,
groups: [{ key: 9, name: 'Actor name', count: 3 }] })
renderWithProviders (<CountsResourcePage resource="posts"/>, {
route: '/ja/counts/posts?axis=user&resource_id=7' })
const link = await screen.findByRole ('link', { name: 'Actor name' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('resource_id')).toBe ('7')
expect (url.searchParams.get ('user_id')).toBe ('9')
expect (url.searchParams.get ('mode')).toBe ('details')
})
it ('keeps partial-month boundaries when drilling through to details', async () => {
fetchCounts.mockResolvedValue ({ count: 4, groupCount: 1, page: 1, limit: 20,
groups: [{ key: '2024-01', name: null, count: 4 }] })
renderWithProviders (<CountsResourcePage resource="tags"/>, {
route: '/ja/counts/tags?axis=month&from=2024-01-15T00:00Z' })
const link = await screen.findByRole ('link', { name: '2024-01' })
const url = new URL (link.getAttribute ('href')!, 'http://localhost')
expect (url.searchParams.get ('from')).toBe ('2024-01-15T00:00Z')
expect (url.searchParams.get ('before')).toBe ('2024-02-01T00:00Z')
expect (url.searchParams.get ('mode')).toBe ('details')
})
})
+138
ファイルの表示
@@ -0,0 +1,138 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import PrefetchLink from '@/components/PrefetchLink'
import CountsControls from '@/components/counts/CountsControls'
import CountsResults from '@/components/counts/CountsResults'
import CountsTableLayout from '@/components/counts/CountsTableLayout'
import PageHead from '@/components/common/PageHead'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsCSVCell } from '@/lib/countsDisplay'
import { countsKeys } from '@/lib/queryKeys'
import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
import type { CountsResource } from '@/lib/counts'
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const [source, setParams] = useSearchParams ()
const params = normaliseCountsParams (resource, source)
const canonical = params.toString ()
const controlParams = new URLSearchParams (params)
controlParams.delete ('nested_json')
controlParams.delete ('order')
const request = countsRequestParams (params)
const [open, setOpen] = useState (false)
const [sidebarOpen, setSidebarOpen] = useState (false)
const locale = useLocale ()
const { label, message } = useUITranslation ('counts')
const mode = params.get ('mode') ?? 'summary'
const axis = params.get ('axis') ?? 'month'
const { data, isPending, isError, isPlaceholderData, refetch } = useQuery ({
queryKey: countsKeys.index (resource, request.toString (), locale),
queryFn: () => fetchCounts (resource, request, locale), retry: false,
placeholderData: (previous, query) => {
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 (() => {
if (source.toString () !== canonical)
setParams (canonical, { replace: true })
}, [canonical, source, setParams])
const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
if (value || key === 'sort') next.set (key, value)
else next.delete (key)
if (key === 'sort') next.delete ('page')
setParams (next)
}
const exportCSV = () => {
const columns = selectedCountColumns (resource, params)
const rows = mode === 'summary'
? [[message (`axes.${axis}`).text, message ('metric').text],
...(data?.groups ?? []).map (group => [
group.key == null ? message ('history:bot').text : group.name ?? String (group.key),
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)], {
type: 'text/csv;charset=utf-8' }))
const link = document.createElement ('a')
link.href = url
link.download = `counts-${resource}.csv`
link.click ()
URL.revokeObjectURL (url)
}
return (
<CountsTableLayout>
<div data-sidebar-container
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageHead title={message (`titles.${resource}`)}/>
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidth={224}
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => (
<li key={target}><PrefetchLink to={`/counts/${target}`}
aria-current={target === resource ? 'page' : undefined}
className={cn (
'block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>))}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="relative shrink-0 pr-16">
<CountsControls key={`${resource}:${controlParams}`}
resource={resource} applied={params}
open={open} setOpen={setOpen}
reorder={value => update ('order', value)}
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}}/>
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0"
title={message ('csvScope').text} lang={message ('csvScope').language}
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}>
{label ('csv')}</Button>
</div>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<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>)
}
export default CountsResourcePage