このコミットが含まれているのは:
@@ -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>)
|
||||
}
|
||||
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする