このコミットが含まれているのは:
2026-10-25 16:00:37 +09:00
コミット 3e05757820
19個のファイルの変更、1692行の追加、388行の削除
+79 -291
ファイルの表示
@@ -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 (
<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>)
}
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 (
<div data-sidebar-container
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageHead title={message (`titles.${resource}`)}/>
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidth={224}
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => (
<li key={target}><PrefetchLink to={`/counts/${target}`}
aria-current={target === resource ? 'page' : undefined}
className={cn ('block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>))}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="relative pr-16">
<CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
open={open} setOpen={setOpen} execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}}/>
<Button type="button" variant="outline" size="sm"
className="absolute right-0 top-0"
title={message ('csvScope').text} lang={message ('csvScope').language}
disabled={data == null || isError} onClick={exportCSV}>{label ('csv')}</Button>
</div>
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<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>}
<CountsTableLayout>
<div data-sidebar-container
className="flex min-h-0 flex-1 flex-col overflow-hidden md:flex-row">
<PageHead title={message (`titles.${resource}`)}/>
<Button type="button" variant="ghost" size="sm" className="shrink-0 md:hidden"
aria-expanded={sidebarOpen} aria-controls="counts-targets"
onClick={() => setSidebarOpen (!sidebarOpen)}>{label ('target')}</Button>
<SidebarComponent sidebarKey="counts" maxWidth={224}
className={cn ('md:block md:max-w-56', !sidebarOpen && 'hidden')}>
<nav id="counts-targets" aria-label={message ('target').text}
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{(['posts', 'tags'] as const).map (target => (
<li key={target}><PrefetchLink to={`/counts/${target}`}
aria-current={target === resource ? 'page' : undefined}
className={cn (
'block py-1', target === resource && 'font-bold underline')}>
{label (target === 'posts' ? 'history:post' : 'navigation:tags')}
</PrefetchLink></li>))}
</ul>
</nav>
</SidebarComponent>
<MainArea className="flex min-h-0 min-w-0 flex-col p-2">
<PageTitle className="mb-1 text-base">{label (`titles.${resource}`)}</PageTitle>
<div className="relative shrink-0 pr-16">
<CountsControls key={`${resource}:${canonical}`} resource={resource} applied={params}
open={open} setOpen={setOpen} execute={next => {
if (next.toString () === canonical) void refetch ()
else setParams (next)
}}/>
<Button type="button" variant="outline" size="sm" className="absolute right-0 top-0"
title={message ('csvScope').text} lang={message ('csvScope').language}
disabled={data == null || isError || isPlaceholderData} onClick={exportCSV}>
{label ('csv')}</Button>
</div>
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>
</div>)
{isPending && <p>{label ('shared:messages.loadingEllipsis')}</p>}
{isError && <p role="alert">{label ('common:fetchError')}</p>}
{data != null && <>
<p className="my-1 text-xs">{label ('total', { count: data.count })}</p>
<CountsResults resource={resource} data={data} params={params}
pending={isPlaceholderData} update={update}/>
<Pagination page={data.page} totalPages={Math.ceil (
(mode === 'summary' ? data.groupCount ?? 0 : data.count) / data.limit)}/>
</>}
</MainArea>
</div>
</CountsTableLayout>)
}