diff --git a/AGENTS.md b/AGENTS.md
index 6b17df2..9d61b3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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
@@ -637,9 +637,9 @@ and layout reuse, follow `frontend/AGENTS.md`.
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
-- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の
- 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。
- この規約はテストコードの追加・修正を義務付けない。
+- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
+ SSR / hydration の整合及び翻訳キーの非露出を検証する。
+ i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
diff --git a/backend/app/controllers/concerns/counts_listing.rb b/backend/app/controllers/concerns/counts_listing.rb
index 80c4660..a1a4769 100644
--- a/backend/app/controllers/concerns/counts_listing.rb
+++ b/backend/app/controllers/concerns/counts_listing.rb
@@ -10,7 +10,8 @@ module CountsListing
if query.mode == 'summary'
result.merge!(query.summary)
else
- result[:versions] = serialise_versions(query.details(version_scope), locale.language_code)
+ result[:versions] = CountsVersionRepr.many(
+ query.details, resource:, language_code: locale.language_code)
end
render json: result
rescue ArgumentError, TypeError
diff --git a/backend/app/representations/counts_version_repr.rb b/backend/app/representations/counts_version_repr.rb
new file mode 100644
index 0000000..e3a7c5b
--- /dev/null
+++ b/backend/app/representations/counts_version_repr.rb
@@ -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
diff --git a/backend/app/services/counts_query.rb b/backend/app/services/counts_query.rb
index 1e62d57..5dde9b1 100644
--- a/backend/app/services/counts_query.rb
+++ b/backend/app/services/counts_query.rb
@@ -1,59 +1,71 @@
-# Read-only history queries. All identifiers and SQL expressions are server-owned.
+# Read-only history queries. SQL identifiers and expressions are server-owned.
class CountsQuery
- EVENTS = ['create', 'update', 'discard', 'restore'].freeze
- AXES = ['event_type', 'month', 'user'].freeze
- COMMON_COLUMNS = ['created_at', 'resource_id', 'version_no', 'event_type', 'user'].freeze
+ AXES = ['month', 'user'].freeze
+ COMMON_COLUMNS = ['resource_id', 'version_no', 'is_latest', 'user', 'created_at'].freeze
COLUMNS = {
- 'posts' => COMMON_COLUMNS + ['title', 'url', 'tags', 'added_tags', 'removed_tags'],
- 'tags' => COMMON_COLUMNS + ['name', 'category', 'aliases', 'parents', 'deprecated'] }.freeze
+ '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',
- 'user_id', 'event_type', 'resource_id', 'tag', 'columns'].freeze
+ 'locale', 'mode', 'axis', 'page', 'limit', 'from', 'before', 'latest',
+ 'user_id', 'resource_id', 'tag', 'columns', 'sort'].freeze
- attr_reader :page, :limit, :mode, :axis, :columns
+ attr_reader :page, :limit, :mode, :axis
def initialize(scope, resource, params)
- @scope = scope.except(:select, :order)
- @resource = resource
@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', 'event_type')
+ @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(',')
+ 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 details(scope)
- scope.where(id: @scope.select("#{ @table }.id"))
- .order(Arel.sql("#{ @table }.created_at DESC, #{ @table }.id DESC"))
+ 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 = case axis
- when 'event_type' then "#{ @table }.event_type"
- when 'user' then "#{ @table }.created_by_user_id"
- when 'month' then "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')"
- end
+ expression = axis == 'month' ? "DATE_FORMAT(#{ @table }.created_at, '%Y-%m')" :
+ "#{ @table }.created_by_user_id"
grouped = @scope.group(Arel.sql(expression))
- # A derived table counts groups without materialising every group in Ruby.
sql = grouped.select(Arel.sql("#{ expression } AS group_key")).to_sql
groups = @scope.klass.connection.select_value("SELECT COUNT(*) FROM (#{ sql }) counts_groups")
- rows = grouped.order(Arel.sql("#{ expression } ASC"))
+ group_order = axis == 'user' ? 'MAX(counts_actor.name)' : expression
+ expressions = { 'group' => [group_order], 'count' => ['COUNT(*)'] }
+ final = ["(#{ expression }) IS NULL ASC", "#{ expression } ASC"]
+ rows = grouped.order(Arel.sql(order_sql(expressions, final)))
.limit(limit).offset((page - 1) * limit).count
names = axis == 'user' ? User.where(id: rows.keys.compact).pluck(:id, :name).to_h : {}
{ group_count: groups.to_i,
@@ -62,6 +74,79 @@ class CountsQuery
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?
@@ -83,22 +168,18 @@ class CountsQuery
first = timestamp('from')
last = timestamp('before')
raise ArgumentError if first && last && first >= last
+ raise ArgumentError unless ['0', '1'].include?(@params.fetch('latest', '0'))
+ @scope = @scope.where(Arel.sql(latest_expression)) if @params['latest'] == '1'
@scope = @scope.where("#{ @table }.created_at >= ?", first) if first
@scope = @scope.where("#{ @table }.created_at < ?", last) if last
if @params['resource_id'].present?
- id_column = @resource == 'posts' ? 'post_id' : 'tag_id'
- @scope = @scope.where(@table => { id_column => integer('resource_id') })
+ @scope = @scope.where(@table => { @id_column => integer('resource_id') })
end
if @params['user_id'].present?
user = @params['user_id'] == 'none' ? nil : integer('user_id')
@scope = @scope.where(@table => { created_by_user_id: user })
end
- if @params['event_type'].present?
- raise ArgumentError unless EVENTS.include?(@params['event_type'])
-
- @scope = @scope.where(@table => { event_type: @params['event_type'] })
- end
return unless @params['tag'].present?
raise ArgumentError unless @resource == 'posts'
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index a7693dc..e4b94ba 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -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
@@ -134,9 +134,9 @@ pass or the remaining failure is clearly blocked.
- 対応 locale の辞書を整合させる。未翻訳項目には既存の日本語 fallback と
`_untranslated` 規約を使い、language metadata を維持する。
翻訳を捏造せず、未翻訳文字列を正式な翻訳として登録しない。
-- UI 文言の追加・変更時は、既存テストの実行、ビルド、静的検査、実画面の
- 確認等で、辞書と SSR / hydration の整合及び翻訳キーの非露出を検証する。
- この規約はテストコードの追加・修正を義務付けない。
+- UI 文言の追加・変更時はビルド、静的検査、実画面の確認等で、辞書と
+ SSR / hydration の整合及び翻訳キーの非露出を検証する。
+ i18n の検証を理由にテスト関連作業を開始しない。
- API の内部識別子、パラメータ、ユーザ入力データ、外部 URL は機械的に
翻訳しない。ボタン、項目名、CSV ヘッダ、エラー、空状態、ツールチップ、
アクセシビリティ用ラベルも UI 文言として扱う。
diff --git a/frontend/src/components/counts/CountsControls.tsx b/frontend/src/components/counts/CountsControls.tsx
new file mode 100644
index 0000000..55068f0
--- /dev/null
+++ b/frontend/src/components/counts/CountsControls.tsx
@@ -0,0 +1,145 @@
+import { useId, useState } from 'react'
+
+import CountsFieldTree from '@/components/counts/CountsFieldTree'
+import DateTimeField from '@/components/common/DateTimeField'
+import FormField from '@/components/common/FormField'
+import { Button } from '@/components/ui/button'
+import { normaliseCountsParams } from '@/lib/counts'
+import { fetchTagByName } from '@/lib/tags'
+import { inputClass } from '@/lib/utils'
+import { useLocale } from '@/lib/useLocale'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { CountsResource } from '@/lib/counts'
+
+
+const CountsControls = (
+ { resource, applied, open, setOpen, execute }: {
+ resource: CountsResource
+ applied: URLSearchParams
+ open: boolean
+ setOpen: (open: boolean) => void
+ execute: (params: URLSearchParams) => void },
+) => {
+ const { label, message } = useUITranslation ('counts')
+ const locale = useLocale ()
+ const id = useId ()
+ const [draft, setDraft] = useState (() => new URLSearchParams (applied))
+ const [error, setError] = useState (false)
+ const [pending, setPending] = useState (false)
+ const mode = draft.get ('mode') ?? 'summary'
+ const update = (key: string, value: string) => {
+ setDraft (previous => {
+ const next = new URLSearchParams (previous)
+ if (key === 'mode') next.delete ('sort')
+ if (value || key === 'columns' || key === 'children') next.set (key, value)
+ else next.delete (key)
+ return next
+ })
+ }
+ const submit = async () => {
+ setError (false)
+ setPending (true)
+ try
+ {
+ const next = new URLSearchParams (draft)
+ for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
+ {
+ const value = next.get (key)?.trim () ?? ''
+ if (value && !/^[1-9][0-9]*$/.test (value))
+ {
+ const tag = await fetchTagByName (value, locale)
+ if (tag == null)
+ throw new Error ()
+ next.set (key, String (tag.id))
+ }
+ }
+ next.delete ('page')
+ execute (normaliseCountsParams (resource, next))
+ }
+ catch
+ {
+ setError (true)
+ }
+ finally
+ {
+ setPending (false)
+ }
+ }
+
+ return (
+
)
+}
+
+
+export default CountsControls
diff --git a/frontend/src/components/counts/CountsFieldTree.tsx b/frontend/src/components/counts/CountsFieldTree.tsx
new file mode 100644
index 0000000..eb12c39
--- /dev/null
+++ b/frontend/src/components/counts/CountsFieldTree.tsx
@@ -0,0 +1,72 @@
+import { countChildPaths, fieldsForCounts, selectedCountChildren,
+ selectedCountColumns } from '@/lib/counts'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { CountField, CountsResource } from '@/lib/counts'
+
+
+const FieldBranch = (
+ { field, path, enabled, selected, select }: {
+ field: CountField
+ path: string
+ enabled: boolean
+ selected: Set
+ select: (path: string, checked: boolean) => void },
+) => {
+ const { label } = useUITranslation ('counts')
+ const checked = selected.has (path)
+ const control =
+ return field.children ? (
+
+ {control}
+
+ {field.children.map (child => (
+ ))}
+
+ ) : {control}
+}
+
+
+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 (roots)
+ if (checked) next.add (path)
+ else next.delete (path)
+ update ('columns', fields.filter (field => next.has (field.key))
+ .map (field => field.key).join (','))
+ return
+ }
+ if (checked) selected.add (path)
+ else selected.delete (path)
+ update ('children', countChildPaths (fields).filter (
+ key => key.includes ('.') && selected.has (key)).join (','))
+ }
+ return (
+ <>
+ {fields.map (field => (
+ ))}
+ {label ('nested.selection')}
+ >)
+}
+
+
+export default CountsFieldTree
diff --git a/frontend/src/components/counts/CountsNestedTable.tsx b/frontend/src/components/counts/CountsNestedTable.tsx
new file mode 100644
index 0000000..37ab970
--- /dev/null
+++ b/frontend/src/components/counts/CountsNestedTable.tsx
@@ -0,0 +1,101 @@
+import LocalisedText from '@/components/LocalisedText'
+import PrefetchLink from '@/components/PrefetchLink'
+import CountsSortHeader from '@/components/counts/CountsSortHeader'
+import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
+import { changeCountSort, readCountSort, selectedCountChildren,
+ writeCountSort } from '@/lib/counts'
+import { countsLogicalName, countsNestedOrder, countsNestedText } from '@/lib/countsDisplay'
+import { useLocale } from '@/lib/useLocale'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { CountField, CountRecord, CountsResource } from '@/lib/counts'
+
+
+const CountsNestedTable = (
+ { rows, fields, path, resource, title, params, update }: {
+ rows: CountRecord[]
+ fields: CountField[]
+ path: string
+ resource: CountsResource
+ title: { text: string; language: string }
+ params: URLSearchParams
+ update: (key: string, value: string) => void },
+) => {
+ const { message } = useUITranslation ('counts')
+ const locale = useLocale ()
+ const selected = selectedCountChildren (params)
+ const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
+ const allSorts = readCountSort (params.get ('child_sort'))
+ const sorts = allSorts.filter (sort => sort.key.startsWith (`${path}/`))
+ .map (sort => ({ ...sort, key: sort.key.slice (path.length + 1) }))
+ const ordered = countsNestedOrder (rows, path, params, locale)
+ const scalar = (row: CountRecord, field: string) => {
+ const value = row[field]
+ if (value == null) return null
+ if (field === 'id')
+ {
+ if (row.kind === 'external') return String (value)
+ const target = path === 'parents' ? resource : 'tags'
+ return
+ {String (value)}
+ }
+ const display = countsLogicalName (row, field, locale)
+ if (typeof value === 'boolean')
+ return {value ? '✓' : '✗'}
+ if (display.language != null)
+ return {display.text}
+ if (field === 'name' && typeof row.languageCode === 'string')
+ return {String (value)}
+ return display.text
+ }
+ return (
+
+
+ {title.text} ({rows.length})
+
+ {columns.length > 0 && rows.length > 0 && (
+
+ field.key)}
+ identity={`${path}:${params}`} name={title}
+ className="border text-xs dark:border-slate-700">
+
+ {columns.map (field => (
+ {
+ 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]))
+ }}/>))}
+
+ {ordered.map ((row, index) => (
+
+ {columns.map (field => (
+ |
+ {field.children
+ ?
+ : scalar (row, field.key)}
+ | ))}
+
))}
+
+
)}
+ )
+}
+
+
+export default CountsNestedTable
diff --git a/frontend/src/components/counts/CountsResizeHandle.tsx b/frontend/src/components/counts/CountsResizeHandle.tsx
new file mode 100644
index 0000000..62718f9
--- /dev/null
+++ b/frontend/src/components/counts/CountsResizeHandle.tsx
@@ -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 } | 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 (
+ {
+ 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
diff --git a/frontend/src/components/counts/CountsResults.tsx b/frontend/src/components/counts/CountsResults.tsx
new file mode 100644
index 0000000..5a21816
--- /dev/null
+++ b/frontend/src/components/counts/CountsResults.tsx
@@ -0,0 +1,149 @@
+import LocalisedText from '@/components/LocalisedText'
+import PrefetchLink from '@/components/PrefetchLink'
+import CountsNestedTable from '@/components/counts/CountsNestedTable'
+import CountsSortHeader from '@/components/counts/CountsSortHeader'
+import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
+import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
+import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
+import { changeCountSort, countColumns, fieldsForCounts,
+ readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
+import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
+import { useLocale } from '@/lib/useLocale'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { CountColumn, CountRecord, CountsResource,
+ CountsResult, CountsVersion } from '@/lib/counts'
+
+
+const CountsResults = (
+ { resource, data, params, pending, update }: {
+ resource: CountsResource
+ data: CountsResult
+ params: URLSearchParams
+ pending: boolean
+ update: (key: string, value: string) => void },
+) => {
+ const { label, message } = useUITranslation ('counts')
+ const locale = useLocale ()
+ const summary = (params.get ('mode') ?? 'summary') === 'summary'
+ const axis = params.get ('axis') ?? 'month'
+ const columns = selectedCountColumns (resource, params)
+ const fields = fieldsForCounts (resource)
+ const sorts = readCountSort (params.get ('sort') ?? (summary ? 'group:asc' : 'created_at:desc'))
+ const sort = (key: string, additive: boolean) =>
+ update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
+ const drillDown = (key: string | number | null): string => {
+ const next = new URLSearchParams (params)
+ next.set ('mode', 'details')
+ next.delete ('page')
+ next.delete ('sort')
+ if (axis === 'month')
+ {
+ const first = `${key}-01T00:00Z`
+ const date = new Date (first)
+ date.setUTCMonth (date.getUTCMonth () + 1)
+ const last = date.toISOString ().slice (0, 16) + 'Z'
+ const from = next.get ('from')
+ const before = next.get ('before')
+ next.set ('from',
+ (from != null && Date.parse (from) > Date.parse (first)) ? from : first)
+ next.set ('before',
+ (before != null && Date.parse (before) < Date.parse (last)) ? before : last)
+ }
+ else next.set ('user_id', String (key ?? 'none'))
+ return `?${next}`
+ }
+ const cell = (row: CountsVersion, column: CountColumn) => {
+ const field = fields.find (field => field.key === column)!
+ const value = countsCellText (row, column, locale)
+ if (field.children != null)
+ {
+ return
+ }
+ switch (column)
+ {
+ case 'resource_id':
+ return
+ {row.resourceId}
+ case 'thumbnail':
+ {
+ const url = countsImageURL (row.thumbnailBase)
+ return url == null ? null :
+ }
+ case 'url':
+ case 'thumbnail_base':
+ return
+ {value}
+ case 'is_latest':
+ return {row.isLatest ? '✓' : '✗'}
+ case 'user':
+ return row.createdByUser?.name ? value : (
+
+ {value})
+ case 'category':
+ return
+ {value}
+ default: return value
+ }
+ }
+ return (
+
+ {!summary && columns.length === 0 ?
{label ('nested.noColumns')}
: (
+
row.id)}`}
+ className="text-left text-sm">
+
+ {summary ? <>
+
+
+ > : columns.map (column => column === 'thumbnail'
+ ? |
+ {label (countColumns[column])}
+
+ |
+ : field.key === column)!.label)}
+ sorts={sorts} change={sort} structured={fields.find (
+ field => field.key === column)?.children != null}/>)}
+
+
+ {summary ? (data.groups ?? []).map (group => (
+
+ |
+
+ {group.key == null
+ ? label ('history:bot') : group.name ?? String (group.key)}
+ |
+ {group.count} |
+
)) : (data.versions ?? []).map (row => (
+
+ {columns.map (column => (
+ | field.key === column)!, params, locale)}>
+ {cell (row, column)} | ))}
+
))}
+
+ )}
+ {(summary ? data.groups?.length ?? 0 : data.versions?.length ?? 0) === 0
+ &&
{label ('empty')}
}
+
)
+}
+
+
+export default CountsResults
diff --git a/frontend/src/components/counts/CountsSelectableTable.tsx b/frontend/src/components/counts/CountsSelectableTable.tsx
new file mode 100644
index 0000000..988895f
--- /dev/null
+++ b/frontend/src/components/counts/CountsSelectableTable.tsx
@@ -0,0 +1,222 @@
+import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
+
+import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
+import { cn } from '@/lib/utils'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { ClipboardEvent, ReactNode } from 'react'
+
+type Point = { row: number; column: number }
+type Selection = { identity: string; anchor: Point; end: Point }
+const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
+
+
+const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
+
+
+const cellAt = (table: HTMLTableElement, point: Point) =>
+ table.tBodies[0]?.rows[point.row]?.cells[point.column]
+
+
+const CountsSelectableTable = (
+ { family, columns, identity, children, className, name }: {
+ family: string
+ columns: string[]
+ identity: string
+ children: ReactNode
+ className?: string
+ name?: { text: string; language: string } },
+) => {
+ const id = useId ()
+ const root = useRef (null)
+ const dragging = useRef (false)
+ const touchArmed = useRef (false)
+ const touchPress = useRef<{ x: number; y: number
+ timer: ReturnType } | null> (null)
+ const [stored, setStored] = useState (null)
+ const selection = stored?.identity === identity ? stored : null
+ const layout = useCountsTableLayout ()
+ const { label, message } = useUITranslation ('counts')
+ const bounds = useMemo (() => selection == null ? null : {
+ top: Math.min (selection.anchor.row, selection.end.row),
+ bottom: Math.max (selection.anchor.row, selection.end.row),
+ left: Math.min (selection.anchor.column, selection.end.column),
+ right: Math.max (selection.anchor.column, selection.end.column) }, [selection])
+ const finish = () => {
+ dragging.current = false
+ touchArmed.current = false
+ if (touchPress.current != null) clearTimeout (touchPress.current.timer)
+ touchPress.current = null
+ }
+ useEffect (() => {
+ const table = root.current
+ const preventPan = (event: TouchEvent) => {
+ if (touchArmed.current) event.preventDefault ()
+ }
+ table?.addEventListener ('touchmove', preventPan, { passive: false })
+ return () => {
+ table?.removeEventListener ('touchmove', preventPan)
+ if (touchPress.current != null) clearTimeout (touchPress.current.timer)
+ }
+ }, [])
+ useLayoutEffect (() => {
+ const rows = root.current?.tBodies[0]?.rows
+ if (rows == null) return
+ for (const [row, tr] of Array.from (rows).entries ())
+ {
+ for (const [column, cell] of Array.from (tr.cells).entries ())
+ {
+ const selected = bounds != null && row >= bounds.top && row <= bounds.bottom
+ && column >= bounds.left && column <= bounds.right
+ cell.id = `${id}-${row}-${column}`
+ cell.setAttribute ('role', 'gridcell')
+ cell.setAttribute ('aria-selected', String (selected))
+ if (selected) cell.dataset.countsSelected = ''
+ else delete cell.dataset.countsSelected
+ }
+ }
+ }, [bounds, children, id])
+ const pointFor = (element: Element | null): Point | null => {
+ const table = root.current
+ if (table == null || element == null || !table.contains (element)) return null
+ let cell = element.closest ('td')
+ while (cell != null && cell.closest ('table') !== table)
+ cell = cell.closest ('table')?.parentElement?.closest ('td') ?? null
+ if (cell == null || cell.parentElement?.parentElement !== table.tBodies[0]) return null
+ return { row: (cell.parentElement as HTMLTableRowElement).sectionRowIndex,
+ column: (cell as HTMLTableCellElement).cellIndex }
+ }
+ const copy = (event: ClipboardEvent) => {
+ if (bounds == null || root.current == null || document.activeElement !== root.current) return
+ event.preventDefault ()
+ event.stopPropagation ()
+ const rows: string[][] = []
+ for (let row = bounds.top; row <= bounds.bottom; ++row)
+ {
+ const values: string[] = []
+ for (let column = bounds.left; column <= bounds.right; ++column)
+ {
+ const cell = cellAt (root.current, { row, column })
+ values.push (cell?.dataset.countsCopy ?? cell?.innerText ?? '')
+ }
+ rows.push (values)
+ }
+ const text = rows.map (row => row.map (value => {
+ const safe = /^\s*[=+@-]/.test (value) ? `'${value}` : value
+ return `"${safe.replaceAll ('"', '""')}"`
+ }).join ('\t')).join ('\r\n')
+ const html = '' + rows.map (row => '' + row.map (value =>
+ `| ${escapeHTML (
+ /^\s*[=+@-]/.test (value) ? `'${value}` : value)} | `).join ('')
+ + '
').join ('') + '
'
+ event.clipboardData.setData ('text/plain', text)
+ try
+ {
+ event.clipboardData.setData ('text/html', html)
+ }
+ catch
+ {
+ // Plain TSV remains available when the browser disallows HTML clipboard data.
+ }
+ }
+ const totalWidth = columns.reduce ((sum, key) => sum + layout.width (family, key), 0)
+ return (
+
+ tbody>tr>td[data-counts-selected]]:bg-blue-100',
+ '[&>tbody>tr>td[data-counts-selected]]:text-slate-900',
+ 'dark:[&>tbody>tr>td[data-counts-selected]]:bg-blue-900',
+ 'dark:[&>tbody>tr>td[data-counts-selected]]:text-slate-100',
+ '[&>tbody>tr>td[data-counts-selected]]:outline',
+ '[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
+ '[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
+ onCopy={copy}
+ onPointerDown={event => {
+ const element = event.target as Element
+ if (element.closest ('table') !== event.currentTarget
+ || element.closest (interactive) != null || event.button !== 0) return
+ const point = pointFor (element)
+ if (point == null) return
+ event.stopPropagation ()
+ event.currentTarget.focus ({ preventScroll: true })
+ setStored ({ identity, anchor: event.shiftKey && selection != null
+ ? selection.anchor : point, end: point })
+ finish ()
+ if (event.pointerType === 'mouse')
+ {
+ event.preventDefault ()
+ dragging.current = true
+ event.currentTarget.setPointerCapture (event.pointerId)
+ }
+ else
+ {
+ const table = event.currentTarget
+ const pointerId = event.pointerId
+ touchPress.current = { x: event.clientX, y: event.clientY,
+ timer: setTimeout (() => {
+ dragging.current = true
+ touchArmed.current = true
+ table.setPointerCapture (pointerId)
+ }, 450) }
+ }
+ }}
+ onPointerMove={event => {
+ if (touchPress.current != null && !touchArmed.current && Math.hypot (
+ event.clientX - touchPress.current.x, event.clientY - touchPress.current.y) > 8)
+ finish ()
+ if (!dragging.current || event.buttons === 0) return
+ const point = pointFor (document.elementFromPoint (event.clientX, event.clientY))
+ if (point == null) return
+ event.stopPropagation ()
+ setStored (previous => previous?.identity !== identity ? previous : {
+ ...previous, end: point })
+ }}
+ onPointerUp={finish} onPointerCancel={finish}
+ onLostPointerCapture={event => {
+ if (event.target === event.currentTarget) finish ()
+ }}
+ onContextMenu={event => {
+ if (touchPress.current != null || touchArmed.current) event.preventDefault ()
+ }}
+ onKeyDown={event => {
+ if (event.target !== event.currentTarget) return
+ if (event.key === 'Escape')
+ {
+ setStored (null)
+ return
+ }
+ if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
+ const rows = event.currentTarget.tBodies[0]?.rows
+ if (rows == null || rows.length === 0) return
+ event.preventDefault ()
+ const previous = selection?.end ?? { row: 0, column: 0 }
+ const key = selection == null ? '' : event.key
+ const point = {
+ row: Math.max (0, Math.min (rows.length - 1, previous.row
+ + (key === 'ArrowDown' ? 1 : (key === 'ArrowUp' ? -1 : 0)))),
+ column: Math.max (0, Math.min (columns.length - 1, previous.column
+ + (key === 'ArrowRight' ? 1 : (key === 'ArrowLeft' ? -1 : 0)))) }
+ setStored ({ identity, anchor: event.shiftKey && selection != null
+ ? selection.anchor : point, end: point })
+ cellAt (event.currentTarget, point)?.scrollIntoView ({
+ block: 'nearest', inline: 'nearest' })
+ }}>
+ {label ('selection.hint')}
+ {columns.map (key => (
+ ))}
+ {children}
+
+ )
+}
+
+
+export default CountsSelectableTable
diff --git a/frontend/src/components/counts/CountsSortHeader.tsx b/frontend/src/components/counts/CountsSortHeader.tsx
new file mode 100644
index 0000000..67711ca
--- /dev/null
+++ b/frontend/src/components/counts/CountsSortHeader.tsx
@@ -0,0 +1,96 @@
+import { useEffect, useRef } from 'react'
+
+import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
+import { Button } from '@/components/ui/button'
+import { useUITranslation } from '@/lib/useUITranslation'
+
+import type { CountSort } from '@/lib/counts'
+
+
+const CountsSortHeader = (
+ { field, label, sorts, change, structured = false }: {
+ field: string
+ label: { text: string; language: string }
+ sorts: CountSort[]
+ change: (key: string, additive: boolean) => void
+ structured?: boolean },
+) => {
+ const { message } = useUITranslation ('counts')
+ const press = useRef<{ x: number; y: number
+ timer: ReturnType } | 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 (
+ 1 && priority >= 0) ? 'other' : state}>
+
+
+ | )
+}
+
+
+export default CountsSortHeader
diff --git a/frontend/src/components/counts/CountsTableLayout.tsx b/frontend/src/components/counts/CountsTableLayout.tsx
new file mode 100644
index 0000000..16a541b
--- /dev/null
+++ b/frontend/src/components/counts/CountsTableLayout.tsx
@@ -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> ({ })
+ 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 (
+
+ {children}
+ )
+}
+
+
+export default CountsTableLayout
diff --git a/frontend/src/lib/counts.ts b/frontend/src/lib/counts.ts
index f6e2730..4b41e33 100644
--- a/frontend/src/lib/counts.ts
+++ b/frontend/src/lib/counts.ts
@@ -1,34 +1,160 @@
import { apiGet } from '@/lib/api'
import type { LocaleCode } from '@/lib/locales'
-import type { PostVersion, TagVersion } from '@/types'
+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?: (PostVersion | TagVersion)[] }
+ versions?: CountsVersion[] }
+export type CountSort = { key: string; direction: 'asc' | 'desc' }
+export type CountField = { key: string; label: string; children?: CountField[] }
+export type CountRecord = Record
export const countColumns = {
- created_at: 'counts:columns.changedAt', resource_id: 'counts:columns.resourceId',
- version_no: 'history:version', user: 'history:actor',
+ 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',
- tags: 'counts:columns.tags', added_tags: 'counts:columns.addedTags',
- removed_tags: 'counts:columns.removedTags', name: 'history:tagName',
- category: 'common:categoryLabel', aliases: 'tags:aliases', parents: 'tags:parents',
- deprecated: 'counts:columns.deprecated' }
-
+ 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> = {
+ tags: [
+ { key: 'id', label: 'counts:columns.resourceId' },
+ { key: 'versionNo', label: 'history:version' },
+ { key: 'kind', label: 'counts:columns.kind' },
+ { key: 'name', label: 'history:name' },
+ { key: 'category', label: 'common:categoryLabel' },
+ { key: 'sections', label: 'counts:columns.sections', children: [
+ { key: 'beginMs', label: 'counts:columns.begin' },
+ { key: 'endMs', label: 'counts:columns.end' }] },
+ { key: 'names', label: 'counts:columns.names', children: nameFields }],
+ names: nameFields,
+ aliases: [{ key: 'languageCode', label: 'counts:columns.language' }, ...aliasFields],
+ parents: [{ key: 'id', label: 'counts:columns.resourceId' }] }
+
export const columnsForCounts = (resource: CountsResource): CountColumn[] => [
- 'created_at', 'resource_id', 'version_no', 'user',
+ ...(resource === 'posts' ? ['thumbnail'] as const : []),
+ 'resource_id', 'version_no', 'is_latest',
...(resource === 'posts'
- ? ['title', 'url', 'tags', 'added_tags', 'removed_tags'] as const
- : ['name', 'category', 'aliases', 'parents', 'deprecated'] as const)]
+ ? ['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 selectedCountColumns = (
+ resource: CountsResource,
+ params: URLSearchParams,
+): CountColumn[] => {
+ const selected = params.has ('columns') ? params.get ('columns')!.split (',') :
+ columnsForCounts (resource).filter (key => ![
+ 'thumbnail', 'original_created_from', 'original_created_before']
+ .includes (key))
+ return columnsForCounts (resource).filter (key => selected.includes (key))
+}
+
+
+export const countChildPaths = (fields: CountField[], prefix = ''): string[] =>
+ fields.flatMap (field => {
+ const path = prefix ? `${prefix}.${field.key}` : field.key
+ return [path, ...countChildPaths (field.children ?? [], path)]
+ })
+
+
+export const selectedCountChildren = (params: URLSearchParams): Set =>
+ 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 = (
@@ -40,25 +166,75 @@ export const normaliseCountsParams = (
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
params.set ('axis', 'month')
if (params.has ('columns'))
- {
- const available = columnsForCounts (resource)
- const columns = params.get ('columns')!.split (',')
- .filter (column => available.includes (column as CountColumn))
- if (columns.length > 0)
- params.set ('columns', [...new Set (columns)].join (','))
- else
- params.delete ('columns')
- }
+ params.set ('columns', selectedCountColumns (resource, params).join (','))
return params
}
+export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
+ const result = new URLSearchParams (params)
+ for (const key of ['columns', 'children', 'child_sort'])
+ result.delete (key)
+ return result
+}
+
+
export const fetchCounts = (
resource: CountsResource,
params: URLSearchParams,
locale: LocaleCode,
): Promise => apiGet (`/counts/${resource}`, {
- locale, params: Object.fromEntries (params) })
+ locale, params: Object.fromEntries (countsRequestParams (params)) })
+
+
+const stableValue = (value: unknown): unknown => {
+ if (Array.isArray (value)) return value.map (stableValue)
+ if (value != null && typeof value === 'object')
+ {
+ return Object.fromEntries (Object.entries (value)
+ .sort (([a], [b]) => a < b ? -1 : (a > b ? 1 : 0))
+ .map (([key, entry]) => [key, stableValue (entry)]))
+ }
+ return value
+}
+
+
+const compareValue = (first: unknown, second: unknown): number => {
+ if (Array.isArray (first) && Array.isArray (second) && first.length !== second.length)
+ return first.length - second.length
+ if (typeof first === 'number' && typeof second === 'number') return first - second
+ if (typeof first === 'boolean' && typeof second === 'boolean')
+ return Number (first) - Number (second)
+ const left = typeof first === 'object' ? JSON.stringify (stableValue (first)) : String (first)
+ const right = typeof second === 'object'
+ ? JSON.stringify (stableValue (second)) : String (second)
+ return left < right ? -1 : (left > right ? 1 : 0)
+}
+
+
+export const sortedCountChildren = (
+ rows: CountRecord[],
+ path: string,
+ params: URLSearchParams,
+ valueFor: (row: CountRecord, key: string) => unknown = (row, key) => row[key],
+): CountRecord[] => {
+ const sort = readCountSort (params.get ('child_sort'))
+ .filter (item => item.key.startsWith (`${path}/`))
+ return rows.map ((row, index) => ({ row, index })).sort ((first, second) => {
+ for (const item of sort)
+ {
+ const key = item.key.slice (path.length + 1)
+ const left = valueFor (first.row, key)
+ const right = valueFor (second.row, key)
+ if (left == null && right != null) return 1
+ if (left != null && right == null) return -1
+ if (left == null && right == null) continue
+ const compared = compareValue (left, right)
+ if (compared !== 0) return item.direction === 'asc' ? compared : -compared
+ }
+ return first.index - second.index
+ }).map (item => item.row)
+}
export const countsCSV = (rows: string[][]): string =>
diff --git a/frontend/src/lib/countsDisplay.ts b/frontend/src/lib/countsDisplay.ts
new file mode 100644
index 0000000..ce7cf20
--- /dev/null
+++ b/frontend/src/lib/countsDisplay.ts
@@ -0,0 +1,190 @@
+import { fieldsForCounts, selectedCountChildren, sortedCountChildren } from '@/lib/counts'
+import { translateUIMessage } from '@/lib/i18n'
+import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
+
+import type { CountColumn, CountField, CountRecord,
+ CountsResource, CountsVersion } from '@/lib/counts'
+import type { LocaleCode } from '@/lib/locales'
+
+
+export const countsCellText = (
+ row: CountsVersion,
+ column: CountColumn,
+ locale: LocaleCode,
+): string => {
+ switch (column)
+ {
+ case 'resource_id': return String (row.resourceId)
+ case 'version_no': return String (row.versionNo)
+ case 'is_latest':
+ return translateUIMessage (`boolean.${row.isLatest}`, 'counts', locale).text
+ case 'title': return row.title ?? ''
+ case 'url': return row.url ?? ''
+ case 'thumbnail':
+ case 'thumbnail_base': return row.thumbnailBase ?? ''
+ case 'name': return row.name ?? ''
+ case 'category':
+ return translateUIMessage (`common:categories.${row.category}`, 'counts', locale).text
+ case 'video_ms': return row.videoMs == null ? '' : msToTime (row.videoMs)
+ case 'original_created_at':
+ return originalCreatedAtString (
+ row.originalCreatedFrom ?? null, row.originalCreatedBefore ?? null, locale)
+ case 'original_created_from':
+ case 'original_created_before':
+ {
+ const value = column === 'original_created_from'
+ ? row.originalCreatedFrom : row.originalCreatedBefore
+ return value == null ? '' : localisedDateString (value, locale)
+ }
+ case 'deprecated':
+ return row.deprecated == null ? '' : localisedDateString (row.deprecated, locale)
+ case 'user':
+ if (row.createdByUser == null)
+ return translateUIMessage ('history:bot', 'counts', locale).text
+ return row.createdByUser.name
+ ? `${row.createdByUser.name} (#${row.createdByUser.id})`
+ : translateUIMessage ('history:anonymous', 'counts', locale, {
+ id: row.createdByUser.id }).text
+ case 'created_at': return localisedDateString (row.createdAt, locale)
+ default: return ''
+ }
+}
+
+
+export const countsCSVCell = (
+ row: CountsVersion,
+ column: CountColumn,
+ resource: CountsResource,
+ params: URLSearchParams,
+ locale: LocaleCode,
+): string => {
+ const field = fieldsForCounts (resource).find (field => field.key === column)!
+ if (field.children != null)
+ {
+ const rows = row[column as keyof CountsVersion] as CountRecord[] ?? []
+ return JSON.stringify (countsNestedExport (rows, field.children, column, params, locale))
+ }
+ return countsCellText (row, column, locale)
+}
+
+
+const displayNames = new Map ()
+
+
+export const countsLogicalName = (
+ row: CountRecord,
+ field: string,
+ locale: LocaleCode,
+): { text: string; language?: string } => {
+ const value = row[field]
+ if (value == null) return { text: '' }
+ const code = String (value)
+ if (field === 'languageCode' || field === 'scriptCode')
+ {
+ if (field === 'languageCode' && ['ja', 'ain'].includes (code))
+ return translateUIMessage (`history:languageNames.${code}`, 'counts', locale)
+ const master = row[field === 'languageCode' ? 'languageName' : 'scriptName']
+ if (typeof master === 'string' && master.trim () !== ''
+ && master.toLowerCase () !== code.toLowerCase ())
+ return { text: master }
+ try
+ {
+ const type = field === 'languageCode' ? 'language' : 'script'
+ if (!displayNames.has (type))
+ displayNames.set (type, new Intl.DisplayNames (['ja'], { type, fallback: 'none' }))
+ const text = displayNames.get (type)!.of (code)
+ if (text != null && text !== code) return { text, language: 'ja' }
+ }
+ catch
+ {
+ // Invalid or unregistered codes have no logical label; never expose the code as a name.
+ }
+ return translateUIMessage ('notRegistered', 'counts', locale)
+ }
+ if (field === 'kind' || field === 'category')
+ return translateUIMessage (field === 'kind' ? `kinds.${code}`
+ : `common:categories.${code}`, 'counts', locale)
+ if (typeof value === 'boolean')
+ return translateUIMessage (`boolean.${value}`, 'counts', locale)
+ if (field === 'beginMs' || field === 'endMs') return { text: msToTime (Number (value)) }
+ return { text: String (value) }
+}
+
+
+export const countsNestedOrder = (
+ rows: CountRecord[],
+ path: string,
+ params: URLSearchParams,
+ locale: LocaleCode,
+): CountRecord[] => sortedCountChildren (rows, path, params, (row, key) =>
+ ['languageCode', 'scriptCode'].includes (key)
+ ? countsLogicalName (row, key, locale).text : row[key])
+
+
+export const countsNestedExport = (
+ rows: CountRecord[],
+ fields: CountField[],
+ path: string,
+ params: URLSearchParams,
+ locale: LocaleCode,
+): unknown => {
+ const selected = selectedCountChildren (params)
+ const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
+ if (columns.length === 0)
+ return { [translateUIMessage ('common:countLabel', 'counts', locale).text]: rows.length }
+ return countsNestedOrder (rows, path, params, locale).map (row =>
+ Object.fromEntries (columns.map (field => [
+ translateUIMessage (field.label, 'counts', locale).text,
+ field.children
+ ? countsNestedExport (row[field.key] as CountRecord[] ?? [], field.children,
+ `${path}.${field.key}`, params, locale)
+ : countsLogicalName (row, field.key, locale).text])))
+}
+
+
+export const countsNestedText = (
+ rows: CountRecord[],
+ fields: CountField[],
+ path: string,
+ params: URLSearchParams,
+ locale: LocaleCode,
+): string => {
+ const selected = selectedCountChildren (params)
+ const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
+ if (columns.length === 0)
+ return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
+ return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
+ const value = field.children
+ ? countsNestedText (row[field.key] as CountRecord[] ?? [], field.children,
+ `${path}.${field.key}`, params, locale)
+ : countsLogicalName (row, field.key, locale).text
+ return `${translateUIMessage (field.label, 'counts', locale).text}: ${value}`
+ }).join (' | ')).join ('\n')
+}
+
+
+export const countsCellCopy = (
+ row: CountsVersion,
+ field: CountField,
+ params: URLSearchParams,
+ locale: LocaleCode,
+): string => field.children
+ ? countsNestedText (row[field.key as keyof CountsVersion] as CountRecord[] ?? [],
+ field.children, field.key, params, locale)
+ : countsCellText (row, field.key as CountColumn, locale)
+
+
+// Historical URLs are displayed without fetching current attachments or metadata.
+export const countsImageURL = (value: string | null | undefined): string | null => {
+ if (!value) return null
+ try
+ {
+ const url = new URL (value)
+ return (['https:', 'http:'].includes (url.protocol) && !url.username && !url.password)
+ ? url.href : null
+ }
+ catch
+ {
+ return null
+ }
+}
diff --git a/frontend/src/lib/countsTableLayout.ts b/frontend/src/lib/countsTableLayout.ts
new file mode 100644
index 0000000..945de5d
--- /dev/null
+++ b/frontend/src/lib/countsTableLayout.ts
@@ -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 = {
+ thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
+ title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
+ video_ms: 120, original_created_at: 260, original_created_from: 240,
+ original_created_before: 240, name: 240, names: 480, category: 180,
+ aliases: 360, deprecated: 240, user: 200, created_at: 240, group: 240, count: 160,
+ id: 88, versionNo: 88, kind: 140, sections: 240, beginMs: 120, endMs: 120,
+ languageCode: 180, scriptCode: 200, autoGenerated: 120 }
+
+type Layout = {
+ width: (family: string, key: string) => number
+ resize: (family: string, key: string, width: number) => void }
+export const CountsLayoutContext = createContext (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
+}
+
+
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index 0afef1c..a4c54e1 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -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') : '',
'〜',
diff --git a/frontend/src/locales/ja/counts.json b/frontend/src/locales/ja/counts.json
index 97e005b..ce6d542 100644
--- a/frontend/src/locales/ja/counts.json
+++ b/frontend/src/locales/ja/counts.json
@@ -22,14 +22,23 @@
"csvScope": "表示中のページ・選択列を CSV 出力",
"empty": "該当する履歴はありません。",
"columns": {
- "changedAt": "変更日時",
- "resourceId": "ID",
+ "changedAt": "操作日時",
+ "resourceId": "Id.",
"postTitle": "投稿タイトル",
"postURL": "投稿 URL",
"tags": "記載タグ",
- "addedTags": "追加されたタグ",
- "removedTags": "削除されたタグ",
- "deprecated": "廃止状態"
+ "deprecated": "廃止状態",
+ "latest": "最新フラグ",
+ "thumbnailURL": "サムネール元 URL",
+ "originalFrom": "オリジナルの投稿日時(開始境界)",
+ "originalBefore": "オリジナルの投稿日時(終了境界・未満)",
+ "language": "言語",
+ "kind": "内部・外部",
+ "sections": "記載区間",
+ "begin": "開始",
+ "end": "終了",
+ "names": "言語別名称",
+ "thumbnailDisplay": "サムネール表示"
},
"fields": {
"from": "開始(端末の時刻帯・以上)",
@@ -38,5 +47,37 @@
"tag": "タグ名または ID",
"actor": "操作者 ID",
"limit": "表示件数"
+ },
+ "latestOnly": "最新のみ",
+ "boolean": {
+ "true": "はい",
+ "false": "いいえ"
+ },
+ "kinds": {
+ "internal": "内部タグ",
+ "external": "外部タグ"
+ },
+ "nested": {
+ "toggle": "表内表を展開・折り畳み",
+ "selection": "親を OFF にしても子項目の選択は保持します。子項目がすべて OFF の表は件数のみ表示します。",
+ "noColumns": "出力項目が選択されていません。"
+ },
+ "sort": {
+ "action": "クリックで単独ソート。Ctrl または長押しでキーを追加・方向変更・解除。NULL は末尾。",
+ "structured": "親表の複数値は要素数、保存スナップショットの文字列表現の順で比較します。Ctrl または長押しで複数キーソート。子表のソートは親表に影響しません。",
+ "label": "{{column}}:{{direction}}、優先順位 {{priority}}",
+ "none": "ソートなし",
+ "ascending": "昇順",
+ "descending": "降順",
+ "unsortedLabel": "{{column}}:ソートなし",
+ "singleLabel": "{{column}}:{{direction}}"
+ },
+ "notRegistered": "名称未登録",
+ "selection": {
+ "table": "集計・明細表",
+ "hint": "クリックでセル選択、ドラッグまたは Shift+クリックで範囲選択。タッチではセルを長押ししてドラッグ。Ctrl+C/Cmd+C でコピー。矢印キーで移動、Shift+矢印キーで範囲を拡張、Escape で解除。"
+ },
+ "resize": {
+ "column": "{{column}}の列幅を変更。境界をドラッグ、タッチでは長押ししてドラッグ。矢印キーでも調整できます。"
}
}
diff --git a/frontend/src/pages/CountsResourcePage.tsx b/frontend/src/pages/CountsResourcePage.tsx
index 07949e5..057e0fd 100644
--- a/frontend/src/pages/CountsResourcePage.tsx
+++ b/frontend/src/pages/CountsResourcePage.tsx
@@ -1,247 +1,72 @@
import { useQuery } from '@tanstack/react-query'
-import { useEffect, useId, useState } from 'react'
+import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
-import LocalisedText from '@/components/LocalisedText'
import PrefetchLink from '@/components/PrefetchLink'
-import DateTimeField from '@/components/common/DateTimeField'
-import FormField from '@/components/common/FormField'
+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 { columnsForCounts, countColumns, countsCSV,
- fetchCounts, normaliseCountsParams } from '@/lib/counts'
+import { countsCSV, countsRequestParams, fieldsForCounts, fetchCounts,
+ normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
+import { countsCSVCell } from '@/lib/countsDisplay'
import { countsKeys } from '@/lib/queryKeys'
-import { fetchTagByName } from '@/lib/tags'
-import { cn, inputClass, localisedDateString } from '@/lib/utils'
+import { cn } from '@/lib/utils'
import { useLocale } from '@/lib/useLocale'
import { useUITranslation } from '@/lib/useUITranslation'
-import type { CountColumn, CountsResource } from '@/lib/counts'
-import type { PostVersion, TagVersion } from '@/types'
-
-
-const CountsControls = (
- { resource, applied, open, setOpen, execute }: {
- resource: CountsResource
- applied: URLSearchParams
- open: boolean
- setOpen: (open: boolean) => void
- execute: (params: URLSearchParams) => void },
-) => {
- const { label, message } = useUITranslation ('counts')
- const locale = useLocale ()
- const id = useId ()
- const [draft, setDraft] = useState (() => new URLSearchParams (applied))
- const [error, setError] = useState (false)
- const [pending, setPending] = useState (false)
- const available = columnsForCounts (resource)
- const columns = (draft.get ('columns')?.split (',') ?? available) as CountColumn[]
- const mode = draft.get ('mode') ?? 'summary'
- const update = (key: string, value: string) => {
- setDraft (previous => {
- const next = new URLSearchParams (previous)
- if (value) next.set (key, value)
- else next.delete (key)
- return next
- })
- }
- const submit = async () => {
- setError (false)
- setPending (true)
- try
- {
- const next = new URLSearchParams (draft)
- for (const key of resource === 'posts' ? ['tag'] : ['resource_id'])
- {
- const value = next.get (key)?.trim () ?? ''
- if (value && !/^[1-9][0-9]*$/.test (value))
- {
- const tag = await fetchTagByName (value, locale)
- if (tag == null)
- throw new Error ()
- next.set (key, String (tag.id))
- }
- }
- next.delete ('page')
- execute (normaliseCountsParams (resource, next))
- }
- catch
- {
- setError (true)
- }
- finally
- {
- setPending (false)
- }
- }
-
- return (
- )
-}
+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 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 columns = (params.get ('columns')?.split (',')
- ?? columnsForCounts (resource)) as CountColumn[]
- const { data, isPending, isError, refetch } = useQuery ({
- queryKey: countsKeys.index (resource, canonical, locale),
- queryFn: () => fetchCounts (resource, params, locale), retry: false })
+ 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 cell = (row: PostVersion | TagVersion, column: CountColumn): string => {
- switch (column)
- {
- case 'created_at': return localisedDateString (row.createdAt, locale)
- case 'resource_id': return String ('postId' in row ? row.postId : row.tagId)
- case 'version_no': return String (row.versionNo)
- case 'user': return row.createdByUser?.name ?? ''
- }
- if ('postId' in row)
- {
- if (column === 'title') return row.title.current ?? ''
- if (column === 'url') return row.url.current
- const tags = row.tags.filter (tag => column === 'added_tags'
- ? tag.type === 'added'
- : (column === 'removed_tags' ? tag.type === 'removed' : tag.type !== 'removed'))
- return tags.map (tag => tag.name).join (' ')
- }
- switch (column)
- {
- case 'name': return row.name.current ?? ''
- case 'category': return message (`common:categories.${row.category.current}`).text
- case 'aliases':
- return row.aliases.filter (item => item.type !== 'removed')
- .map (item => item.name).join (' ')
- case 'parents':
- return row.parentTags.filter (item => item.type !== 'removed')
- .map (item => item.tag.name).join (' ')
- case 'deprecated': return row.deprecatedAt.current ?? ''
- default: return ''
- }
- }
- const groups = data?.groups ?? []
- const versions = data?.versions ?? []
- const groupName = (group: typeof groups[number]) =>
- axis === 'user' ? group.name ?? String (group.key ?? '') : String (group.key)
- const drillDown = (key: string | number | null): string => {
+ const update = (key: string, value: string) => {
const next = new URLSearchParams (params)
- next.set ('mode', 'details')
- next.delete ('page')
- 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 && from > first ? from : first)
- next.set ('before', before != null && before < last ? before : last)
- }
- else
- next.set ('user_id', String (key ?? 'none'))
- return `?${next}`
+ if (value || key === 'sort') next.set (key, value)
+ else next.delete (key)
+ if (key === 'sort') next.delete ('page')
+ setParams (next)
}
- const groupText = (group: typeof groups[number]) =>
- group.key == null ? message ('history:bot').text : groupName (group)
const exportCSV = () => {
+ const columns = selectedCountColumns (resource, params)
const rows = mode === 'summary'
? [[message (`axes.${axis}`).text, message ('metric').text],
- ...groups.map (group => [groupText (group), String (group.count)])]
- : [columns.map (column => message (countColumns[column]).text),
- ...versions.map (row => columns.map (column => cell (row, column)))]
+ ...(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')
@@ -252,91 +77,54 @@ const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
}
return (
-
-
-
-
-
-
-
- {label (`titles.${resource}`)}
-
- {
- if (next.toString () === canonical) void refetch ()
- else setParams (next)
- }}/>
-
-
- {isPending && {label ('shared:messages.loadingEllipsis')}
}
- {isError && {label ('common:fetchError')}
}
- {data != null && <>
- {label ('total', { count: data.count })}
-
-
-
- {mode === 'summary'
- ? <>| {label (`axes.${axis}`)} |
- {label ('metric')} | >
- : <>
- {label (resource === 'posts' ? 'history:post' : 'navigation:tags')} |
- {columns.map (column => (
-
- {label (countColumns[column])} | ))}>}
-
-
- {mode === 'summary' ? groups.map (group => (
-
- |
- {group.key == null ? label ('history:bot') : groupName (group)}
- |
- {group.count} |
-
)) : versions.map (row => {
- const id = 'postId' in row ? row.postId : row.tagId
- return
- | #{id} |
- {columns.map (column => (
-
- {column === 'url'
- ?
- {cell (row, column)}
- : (column === 'category' && 'tagId' in row
- ?
- {cell (row, column)}
- : cell (row, column))} | ))}
-
- })}
-
-
- {(mode === 'summary' ? groups.length : versions.length) === 0
- &&
{label ('empty')}
}
+
+
+
+
+
+
+
+
+ {label (`titles.${resource}`)}
+
+ {
+ if (next.toString () === canonical) void refetch ()
+ else setParams (next)
+ }}/>
+
-
- >}
-
-
)
+ {isPending && {label ('shared:messages.loadingEllipsis')}
}
+ {isError && {label ('common:fetchError')}
}
+ {data != null && <>
+ {label ('total', { count: data.count })}
+
+
+ >}
+
+
+ )
}