このコミットが含まれているのは:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { ClipboardEvent, ReactNode } from 'react'
|
||||
|
||||
type Point = { row: number; column: number }
|
||||
type Selection = { identity: string; anchor: Point; end: Point }
|
||||
const interactive = 'a,button,input,select,textarea,summary,[contenteditable],[data-counts-resize]'
|
||||
|
||||
|
||||
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||
|
||||
|
||||
const cellAt = (table: HTMLTableElement, point: Point) =>
|
||||
table.tBodies[0]?.rows[point.row]?.cells[point.column]
|
||||
|
||||
|
||||
const CountsSelectableTable = (
|
||||
{ family, columns, identity, children, className, name }: {
|
||||
family: string
|
||||
columns: string[]
|
||||
identity: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
name?: { text: string; language: string } },
|
||||
) => {
|
||||
const id = useId ()
|
||||
const root = useRef<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
|
||||
@@ -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
|
||||
@@ -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
|
||||
新しいイシューから参照
ユーザーをブロックする