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