このコミットが含まれているのは:
@@ -8,13 +8,13 @@ import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react'
|
||||
const pad = (n: number): string => n.toString ().padStart (2, '0')
|
||||
|
||||
|
||||
const toDateTimeLocalValue = (value: Date) => {
|
||||
const toDateTimeLocalValue = (value: Date, seconds = false) => {
|
||||
const y = value.getFullYear ()
|
||||
const m = pad (value.getMonth () + 1)
|
||||
const day = pad (value.getDate ())
|
||||
const h = pad (value.getHours ())
|
||||
const min = pad (value.getMinutes ())
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }`
|
||||
return `${ y }-${ m }-${ day }T${ h }:${ min }${seconds ? `:${pad (value.getSeconds ())}` : ''}`
|
||||
}
|
||||
|
||||
|
||||
@@ -37,15 +37,17 @@ type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
|
||||
onChange?: (isoUTC: string | null) => void
|
||||
className?: string
|
||||
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void
|
||||
invalid?: boolean }
|
||||
invalid?: boolean
|
||||
precision?: 'minute' | 'second' }
|
||||
|
||||
|
||||
const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid, ...rest }) => {
|
||||
const DateTimeField: FC<Props> = props => {
|
||||
const { value, onChange, className, onBlur, invalid, precision = 'minute', ...rest } = props
|
||||
const [local, setLocal] = useState ('')
|
||||
|
||||
useEffect (() => {
|
||||
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
|
||||
}, [value])
|
||||
setLocal (value ? toDateTimeLocalValue (new Date (value), precision === 'second') : '')
|
||||
}, [value, precision])
|
||||
|
||||
return (
|
||||
<input
|
||||
@@ -61,13 +63,15 @@ const DateTimeField: FC<Props> = ({ value, onChange, className, onBlur, invalid,
|
||||
'focus:ring-2 focus:ring-blue-200']),
|
||||
className)}
|
||||
type="datetime-local"
|
||||
step={60}
|
||||
step={precision === 'second' ? 1 : 60}
|
||||
value={local}
|
||||
aria-invalid={invalid}
|
||||
onChange={ev => {
|
||||
const v = ev.target.value
|
||||
setLocal (v)
|
||||
onChange?.(v ? toMinutePrecisionIsoUtc (v) : null)
|
||||
const iso = v ? (precision === 'second'
|
||||
? new Date (v).toISOString () : toMinutePrecisionIsoUtc (v)) : null
|
||||
onChange?.(iso)
|
||||
}}
|
||||
onBlur={onBlur}/>)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useId, useRef } from 'react'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
|
||||
const HelpPopover = ({ label, children }: {
|
||||
label: { text: string; language: string }; children: ReactNode },
|
||||
) => {
|
||||
const id = useId ()
|
||||
const trigger = useRef<HTMLButtonElement> (null)
|
||||
const panel = useRef<HTMLDivElement> (null)
|
||||
return <>
|
||||
<button ref={trigger} type="button" popoverTarget={id} aria-label={label.text}
|
||||
lang={label.language} className="inline-flex align-middle"
|
||||
onClick={() => {
|
||||
const rect = trigger.current?.getBoundingClientRect ()
|
||||
if (panel.current != null && rect != null)
|
||||
{
|
||||
panel.current.style.top = `${rect.bottom + 4}px`
|
||||
panel.current.style.left = `${Math.min (rect.left, window.innerWidth - 288)}px`
|
||||
}
|
||||
}}><CircleHelp size={14}/></button>
|
||||
<div ref={panel} id={id} popover="auto" tabIndex={-1}
|
||||
role="dialog" aria-label={label.text} lang={label.language}
|
||||
onToggle={event => {
|
||||
if (event.newState === 'open') panel.current?.focus ()
|
||||
}} className="fixed m-0 w-72 max-w-[90vw] rounded border bg-white p-3
|
||||
text-sm text-slate-900 shadow-lg dark:border-slate-600 dark:bg-slate-900
|
||||
dark:text-slate-100">{children}</div>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
export default HelpPopover
|
||||
@@ -12,6 +12,7 @@ import type { Tag } from '@/types'
|
||||
|
||||
type Props = {
|
||||
includeNico?: boolean
|
||||
includeUnused?: boolean
|
||||
describedBy?: string
|
||||
invalid?: boolean
|
||||
value: string
|
||||
@@ -20,6 +21,7 @@ type Props = {
|
||||
|
||||
const TagInput: FC<Props> = ({
|
||||
includeNico = false,
|
||||
includeUnused = false,
|
||||
describedBy,
|
||||
invalid,
|
||||
value,
|
||||
@@ -57,11 +59,11 @@ const TagInput: FC<Props> = ({
|
||||
{
|
||||
const tags = await apiGet<Tag[]> ('/tags/autocomplete', {
|
||||
locale, signal: controller.signal,
|
||||
params: { q: request.query, nico: includeNico ? '1' : '0' } })
|
||||
params: { q: request.query.replace (/^not:/i, ''), nico: includeNico ? '1' : '0' } })
|
||||
if (active)
|
||||
{
|
||||
setResult ({ locale, includeNico, request,
|
||||
tags: tags.filter (tag => tag.postCount > 0) })
|
||||
tags: includeUnused ? tags : tags.filter (tag => tag.postCount > 0) })
|
||||
}
|
||||
}
|
||||
catch
|
||||
@@ -74,7 +76,7 @@ const TagInput: FC<Props> = ({
|
||||
active = false
|
||||
controller.abort ()
|
||||
}
|
||||
}, [request, locale, includeNico])
|
||||
}, [request, locale, includeNico, includeUnused])
|
||||
|
||||
useEffect (() => {
|
||||
setActiveIndex (-1)
|
||||
@@ -85,7 +87,8 @@ const TagInput: FC<Props> = ({
|
||||
// TODO: TagSearch からのコピペのため,共通化を考へる.
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
const parts = value?.split (' ')
|
||||
parts[parts.length - 1] = tag.name
|
||||
const negative = /^not:/i.test (parts[parts.length - 1])
|
||||
parts[parts.length - 1] = `${negative ? 'not:' : ''}${tag.name}`
|
||||
setValue (parts.join (' ') + ' ')
|
||||
setRequest ({ query: '' })
|
||||
setActiveIndex (-1)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { countsFieldLabel, countsFilterFields, countsOperators, readCountsJSON }
|
||||
from '@/lib/countsDefinitions'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
import type { CountsCondition } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const ConditionEditor = ({ resource, initial, save }: {
|
||||
resource: CountsResource; initial?: CountsCondition
|
||||
save: (condition: CountsCondition) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = countsFilterFields (resource)
|
||||
const [field, setField] = useState (initial?.field ?? 'resource_id')
|
||||
const [operator, setOperator] = useState (initial?.operator ?? 'eq')
|
||||
return <div className="space-y-2">
|
||||
<select value={field} aria-label={message ('field').text} className={inputClass ()}
|
||||
onChange={event => { setField (event.target.value); setOperator ('eq') }}>
|
||||
{Object.keys (fields).filter (key => fields[key] !== 'json').map (key => (
|
||||
<option key={key} value={key}>{message (countsFieldLabel (key)).text}</option>))}
|
||||
</select>
|
||||
<select value={operator} aria-label={message ('operator').text} className={inputClass ()}
|
||||
onChange={event => setOperator (event.target.value)}>
|
||||
{countsOperators[fields[field]].map (key => <option key={key} value={key}>
|
||||
{message (`operators.${key}`).text}</option>)}
|
||||
</select>
|
||||
<Button type="button" onClick={() => save ({ ...initial, field, operator })}>
|
||||
{label (initial == null ? 'add' : 'shared:actions.edit')}</Button>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const CountsAdditionalConditions = ({ resource, params, update }: {
|
||||
resource: CountsResource; params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const dialogue = useDialogue ()
|
||||
const conditions = readCountsJSON<CountsCondition[]> (params, 'conditions', [])
|
||||
const write = (next: CountsCondition[]) => update ('conditions', JSON.stringify (next))
|
||||
const edit = (index?: number) => {
|
||||
void dialogue.form ({ title: label ('additionalConditions'), body: controls => (
|
||||
<ConditionEditor resource={resource} initial={index == null ? undefined : conditions[index]}
|
||||
save={condition => {
|
||||
write (index == null ? [...conditions, condition]
|
||||
: conditions.map ((item, position) => position === index ? condition : item))
|
||||
controls.close ()
|
||||
}}/>) })
|
||||
}
|
||||
const patch = (index: number, change: Partial<CountsCondition>) => write (
|
||||
conditions.map ((item, position) => position === index ? { ...item, ...change } : item))
|
||||
return <fieldset className="space-y-1">
|
||||
<legend className="flex items-center gap-2">{label ('additionalConditions')}
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => edit ()}>
|
||||
{label ('add')}</Button></legend>
|
||||
{conditions.map ((condition, index) => {
|
||||
const datetime = countsFilterFields (resource)[condition.field] === 'datetime'
|
||||
return <div key={index} className="flex flex-wrap items-center gap-1">
|
||||
<button type="button" onClick={() => edit (index)}>
|
||||
{label (countsFieldLabel (condition.field))} · {label (`operators.${condition.operator}`)}
|
||||
</button>
|
||||
{(condition.operator === 'range' ? ['value', 'end'] as const : ['value'] as const)
|
||||
.map (key => datetime ? <DateTimeField precision="second" key={key}
|
||||
value={condition[key] ?? ''} disabled={condition.blank}
|
||||
aria-label={message (key === 'end' ? 'through' : 'value').text}
|
||||
onChange={value => patch (index, { [key]: value ?? '' })}/> : (
|
||||
<input key={key} value={condition[key] ?? ''} disabled={condition.blank}
|
||||
className={inputClass (false, 'w-36 py-1')}
|
||||
aria-label={message ('value').text}
|
||||
onChange={event => patch (index, { [key]: event.target.value })}/>))}
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={condition.blank ?? false}
|
||||
onChange={event => patch (index, { blank: event.target.checked })}/>
|
||||
{label ('blank')}</label>
|
||||
<button type="button" aria-label={message ('remove').text}
|
||||
onClick={() => write (conditions.filter ((_, position) => index !== position))}>×</button>
|
||||
</div>
|
||||
})}
|
||||
</fieldset>
|
||||
}
|
||||
|
||||
|
||||
export default CountsAdditionalConditions
|
||||
@@ -0,0 +1,125 @@
|
||||
import { DndContext, MouseSensor, TouchSensor, closestCenter,
|
||||
useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
|
||||
import { countsAxisFields, countsFilterFields, countsGrains, readCountsJSON }
|
||||
from '@/lib/countsDefinitions'
|
||||
import { cn, inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { CountsResource } from '@/lib/counts'
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const AxisItem = ({ id, name, children }: { id: string; name: string; children: ReactNode }) => {
|
||||
const drag = useDraggable ({ id })
|
||||
const drop = useDroppable ({ id })
|
||||
return <div ref={drop.setNodeRef} className={cn (
|
||||
'flex flex-wrap items-center gap-1 rounded border p-1 dark:border-slate-600',
|
||||
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
||||
<button ref={drag.setNodeRef} type="button" {...drag.listeners} {...drag.attributes}
|
||||
className="cursor-grab touch-none" aria-label={name}>
|
||||
<span aria-hidden>⠿</span>
|
||||
</button>{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const AxisZone = ({ id, children }: { id: string; children: ReactNode }) => {
|
||||
const drop = useDroppable ({ id })
|
||||
return <div ref={drop.setNodeRef} className={cn (
|
||||
'flex min-h-12 flex-wrap items-start gap-1 rounded border p-1 dark:border-slate-600',
|
||||
drop.isOver && 'outline outline-1 outline-blue-500 dark:outline-blue-400')}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
const CountsAxes = ({ resource, params, update }: {
|
||||
resource: CountsResource; params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const history = params.get ('definition') === 'history'
|
||||
const available = countsAxisFields (resource, history)
|
||||
const rows = readCountsJSON<CountsAxis[]> (params, 'rows', [])
|
||||
const cols = readCountsJSON<CountsAxis[]> (params, 'cols', [])
|
||||
const groups = { rows, cols }
|
||||
const sensors = useSensors (
|
||||
useSensor (MouseSensor, { activationConstraint: { distance: 4 } }),
|
||||
useSensor (TouchSensor, { activationConstraint: { delay: 350, tolerance: 8 } }))
|
||||
const place = (field: string, target: string, position?: string) => {
|
||||
for (const key of ['rows', 'cols'] as const)
|
||||
{
|
||||
const existing = [...rows, ...cols].find (axis => axis.field === field)
|
||||
const next = groups[key].filter (axis => axis.field !== field)
|
||||
if (target === key)
|
||||
{
|
||||
const index = position == null ? next.length : next.findIndex (
|
||||
axis => axis.field === position)
|
||||
next.splice (index < 0 ? next.length : index, 0, existing ?? {
|
||||
field, ...(countsFilterFields (resource)[field] === 'datetime'
|
||||
? { grain: 'month' } : { }) })
|
||||
}
|
||||
update (key, JSON.stringify (next))
|
||||
}
|
||||
}
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
accessibility={{ announcements: {
|
||||
onDragStart: () => undefined, onDragOver: () => undefined,
|
||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: message ('axis').text } }}
|
||||
onDragEnd={({ active, over }) => {
|
||||
if (over == null) return
|
||||
const [, field] = String (active.id).split ('/')
|
||||
const [zone, position] = String (over.id).split ('/')
|
||||
place (field, zone, position)
|
||||
}}>
|
||||
<div className="space-y-2">
|
||||
<fieldset><legend>{label ('axisItems')}</legend><AxisZone id="available">
|
||||
{available.filter (field => ![...rows, ...cols].some (
|
||||
axis => axis.field === field.key)).map (field => (
|
||||
<AxisItem key={field.key} id={`available/${field.key}`} name={message (field.label).text}>
|
||||
{label (field.label)}
|
||||
{(['rows', 'cols'] as const).map (target => <button key={target} type="button"
|
||||
aria-label={message ('axisPlace', {
|
||||
field: message (field.label).text, target: message (target).text }).text}
|
||||
onClick={() => place (field.key, target)}>{label (target)}</button>)}
|
||||
</AxisItem>))}
|
||||
</AxisZone></fieldset>
|
||||
{(['rows', 'cols'] as const).map (zone => <fieldset key={zone}>
|
||||
<legend>{label (zone)}</legend><AxisZone id={zone}>
|
||||
{groups[zone].map ((axis, index) => {
|
||||
const field = available.find (field => field.key === axis.field)
|
||||
return <AxisItem key={axis.field} id={`${zone}/${axis.field}`}
|
||||
name={field == null ? message ('axis').text : message (field.label).text}>
|
||||
{field == null ? null : label (field.label)}
|
||||
{countsFilterFields (resource)[axis.field] === 'datetime' && <select
|
||||
className={inputClass (false, 'w-auto py-0')}
|
||||
value={axis.grain ?? 'month'} aria-label={message ('grain').text}
|
||||
onChange={event => update (zone, JSON.stringify (groups[zone].map (
|
||||
item => item === axis ? { ...item, grain: event.target.value } : item)))}>
|
||||
{countsGrains.map (grain => <option key={grain} value={grain}>
|
||||
{message (`grains.${grain}`).text}</option>)}
|
||||
</select>}
|
||||
<button type="button" aria-label={message ('reorder', {
|
||||
column: field == null ? '' : message (field.label).text }).text}
|
||||
disabled={index === 0}
|
||||
onClick={() => place (axis.field, zone, groups[zone][index - 1]?.field)}>↑</button>
|
||||
<button type="button" onClick={() => place (
|
||||
axis.field, zone === 'rows' ? 'cols' : 'rows')}>
|
||||
{label (zone === 'rows' ? 'cols' : 'rows')}</button>
|
||||
<button type="button" aria-label={message ('remove').text}
|
||||
onClick={() => place (axis.field, 'available')}>×</button>
|
||||
</AxisItem>
|
||||
})}
|
||||
</AxisZone>
|
||||
</fieldset>)}
|
||||
</div>
|
||||
</DndContext>
|
||||
}
|
||||
|
||||
|
||||
export default CountsAxes
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useId, useState } from 'react'
|
||||
|
||||
import CountsAdditionalConditions from '@/components/counts/CountsAdditionalConditions'
|
||||
import CountsAxes from '@/components/counts/CountsAxes'
|
||||
import CountsFieldTree from '@/components/counts/CountsFieldTree'
|
||||
import DateTimeField from '@/components/common/DateTimeField'
|
||||
import FormField from '@/components/common/FormField'
|
||||
import HelpPopover from '@/components/common/HelpPopover'
|
||||
import TagInput from '@/components/common/TagInput'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { isCountsState, normaliseCountsParams } from '@/lib/counts'
|
||||
import { countsSummaryFields } from '@/lib/countsState'
|
||||
import { fetchTagByName } from '@/lib/tags'
|
||||
import { normaliseCountsParams } from '@/lib/counts'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -18,17 +19,13 @@ import type { CountsResource } from '@/lib/counts'
|
||||
|
||||
const CountsControls = (
|
||||
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
|
||||
resource: CountsResource
|
||||
applied: URLSearchParams
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
execute: (params: URLSearchParams) => void
|
||||
displayJSON: (enabled: boolean) => void
|
||||
reorder: (value: string) => void
|
||||
actions?: ReactNode },
|
||||
resource: CountsResource; applied: URLSearchParams; open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
execute: (params: URLSearchParams) => void
|
||||
displayJSON: (enabled: boolean) => void
|
||||
reorder: (value: string) => void; actions?: ReactNode },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const locale = useLocale ()
|
||||
const id = useId ()
|
||||
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
||||
const order = applied.get ('order')
|
||||
@@ -38,175 +35,96 @@ const CountsControls = (
|
||||
const next = new URLSearchParams (previous)
|
||||
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
|
||||
{
|
||||
if (value == null) next.delete (key)
|
||||
else next.set (key, value)
|
||||
if (value == null) next.delete (key)
|
||||
else next.set (key, value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [order, nestedJSON])
|
||||
const [error, setError] = useState (false)
|
||||
const [pending, setPending] = useState (false)
|
||||
const mode = draft.get ('mode') ?? 'summary'
|
||||
const state = isCountsState (draft)
|
||||
const axes = (state && resource === 'posts') ? ['tags', 'month', 'user'] : ['month', 'user']
|
||||
const update = (key: string, value: string) => {
|
||||
setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
if (['mode', 'axis', 'basis'].includes (key)) next.delete ('sort')
|
||||
if (key === 'axis' || key === 'basis') next.delete ('summary_columns')
|
||||
if (key === 'basis' && value === 'history' && next.get ('axis') === 'tags')
|
||||
next.set ('axis', 'month')
|
||||
if (value || ['columns', 'children', 'summary_columns'].includes (key))
|
||||
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))
|
||||
{
|
||||
if (state && next.get ('kind') === 'external') throw new Error ()
|
||||
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)}>
|
||||
{['summary', 'details'].map (value => (
|
||||
<option key={value} value={value} lang={message (value).language}>
|
||||
{message (value).text}</option>))}
|
||||
</select>
|
||||
{actions}
|
||||
</div>
|
||||
{error && <p role="alert">{label ('shared:messages.checkInputModern')}</p>}
|
||||
<div id={id} hidden={!open}
|
||||
className="max-h-[40dvh] overflow-y-auto overscroll-contain border-b py-2
|
||||
dark:border-slate-700 md:max-h-[45dvh]">
|
||||
<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>
|
||||
<label className="mb-2 flex items-center gap-1">
|
||||
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
|
||||
onChange={event => {
|
||||
update ('nested_json', event.target.checked ? '1' : '0')
|
||||
displayJSON (event.target.checked)
|
||||
}}/>
|
||||
{label ('nested.asJSON')}
|
||||
</label>
|
||||
<FormField label={label ('measure')}>
|
||||
{() => <select value={state ? 'state' : 'history'}
|
||||
className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('measure').text} lang={message ('measure').language}
|
||||
onChange={event => update ('basis', event.target.value)}>
|
||||
<option value="state" lang={message (`state.totals.${resource}`).language}>
|
||||
{message (`state.totals.${resource}`).text}</option>
|
||||
<option value="history" lang={message ('metric').language}>
|
||||
{message ('metric').text}</option>
|
||||
</select>}
|
||||
</FormField>
|
||||
{mode === 'summary' && <label className="my-2 flex flex-wrap items-center gap-2">
|
||||
{label ('axis')}
|
||||
<select className={inputClass (false, 'w-auto py-1')}
|
||||
value={draft.get ('axis')
|
||||
?? ((state && resource === 'posts') ? 'tags' : 'month')}
|
||||
aria-label={message ('axis').text} lang={message ('axis').language}
|
||||
onChange={event => update ('axis', event.target.value)}>
|
||||
{axes.map (axis => <option key={axis} value={axis}
|
||||
lang={message (`axes.${axis}`).language}>
|
||||
{message (`axes.${axis}`).text}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
<CountsFieldTree resource={resource} params={draft}
|
||||
fields={mode === 'summary' ? countsSummaryFields (resource, draft) : undefined}
|
||||
selectionKey={mode === 'summary' ? 'summary_columns' : 'columns'}
|
||||
parent={mode === 'summary' ? 'summary' : ''} update={(key, value) => {
|
||||
update (key, value)
|
||||
if (key === 'order') reorder (value)
|
||||
}}/>
|
||||
</fieldset>
|
||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||
<legend className="mb-2 font-semibold">{label ('filters')}</legend>
|
||||
<FormField label={label ('state.at')}>
|
||||
{() => <DateTimeField value={draft.get ('at') ?? ''}
|
||||
aria-label={message ('state.at').text} lang={message ('state.at').language}
|
||||
onChange={value => update ('at', value ?? '')}/>}
|
||||
</FormField>
|
||||
{!state && <label className="flex items-center gap-1">
|
||||
<input type="checkbox" checked={draft.get ('latest') !== '0'}
|
||||
onChange={event => update ('latest', event.target.checked ? '1' : '0')}/>
|
||||
{label ('latestOnly')}
|
||||
</label>}
|
||||
{state && resource === 'posts' && <FormField label={label ('columns.kind')}>
|
||||
{() => <select value={draft.get ('kind') ?? 'internal'}
|
||||
className={inputClass (false, 'w-auto py-1')}
|
||||
aria-label={message ('columns.kind').text} lang={message ('columns.kind').language}
|
||||
onChange={event => update ('kind', event.target.value)}>
|
||||
{['internal', 'external'].map (kind => <option key={kind} value={kind}
|
||||
lang={message (`kinds.${kind}`).language}>
|
||||
{message (`kinds.${kind}`).text}</option>)}
|
||||
</select>}
|
||||
</FormField>}
|
||||
<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 history = draft.get ('definition') === 'history'
|
||||
const update = (key: string, value: string) => setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
if (['mode', 'rows', 'cols'].includes (key)) next.delete ('sort')
|
||||
next.set (key, value)
|
||||
return next
|
||||
})
|
||||
return <form className="shrink-0" onSubmit={event => {
|
||||
event.preventDefault ()
|
||||
const next = new URLSearchParams (draft)
|
||||
next.delete ('page')
|
||||
next.delete ('execution_at')
|
||||
execute (normaliseCountsParams (resource, next))
|
||||
}}>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b pb-1 dark:border-slate-700">
|
||||
{history ? <>
|
||||
{['from', 'through'].map (key => <label key={key}
|
||||
className="flex flex-wrap items-center gap-1 text-sm">
|
||||
{label (key)}<DateTimeField precision="second" value={draft.get (key) ?? ''}
|
||||
aria-label={message (key).text} onChange={value => update (key, value ?? '')}/>
|
||||
</label>)}
|
||||
</> : <label className="flex flex-wrap items-center gap-1 text-sm">
|
||||
{label ('referenceAt')}<HelpPopover label={message ('referenceAt')}>
|
||||
{label ('referenceHelp')}</HelpPopover>
|
||||
<DateTimeField precision="second" value={draft.get ('at') ?? ''}
|
||||
aria-label={message ('referenceAt').text}
|
||||
onChange={value => update ('at', value ?? '')}/>
|
||||
</label>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b py-1 dark:border-slate-700">
|
||||
<Button type="submit" size="sm">{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)}>
|
||||
{['summary', 'details'].map (value => <option key={value} value={value}>
|
||||
{message (value).text}</option>)}
|
||||
</select>{actions}
|
||||
</div>
|
||||
<div id={id} hidden={!open} className="max-h-[35dvh] overflow-y-auto overscroll-contain
|
||||
border-b py-2 dark:border-slate-700 md:max-h-[40dvh]">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<fieldset className="min-w-0" data-counts-output>
|
||||
{mode === 'summary' ? <CountsAxes resource={resource} params={draft} update={update}/>
|
||||
: <>
|
||||
<legend className="mb-1 font-semibold">{label ('output')}</legend>
|
||||
<label className="mb-1 flex items-center gap-1">
|
||||
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
|
||||
onChange={event => {
|
||||
update ('nested_json', event.target.checked ? '1' : '0')
|
||||
displayJSON (event.target.checked)
|
||||
}}/>{label ('nested.asJSON')}</label>
|
||||
<CountsFieldTree resource={resource} params={draft} update={(key, value) => {
|
||||
update (key, value)
|
||||
if (key === 'order') reorder (value)
|
||||
}}/>
|
||||
</>}
|
||||
</fieldset>
|
||||
<fieldset className="min-w-0 space-y-2" data-counts-filters>
|
||||
<legend className="font-semibold">{label ('filters')}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['resource_id', 'user_id', 'limit'].map (key => <FormField key={key}
|
||||
label={label (key === 'resource_id' ? 'columns.resourceId'
|
||||
: (key === 'user_id' ? 'fields.actor' : 'fields.limit'))}>
|
||||
{() => <input value={draft.get (key) ?? (key === 'limit' ? '20' : '')}
|
||||
aria-label={message (key === 'resource_id' ? 'columns.resourceId'
|
||||
: (key === 'user_id' ? 'fields.actor' : 'fields.limit')).text}
|
||||
className={inputClass (false, 'w-28 py-1')}
|
||||
onChange={event => update (key, event.target.value)}/>}
|
||||
</FormField>)}
|
||||
</div>
|
||||
{resource === 'posts' && <FormField label={label ('columns.tags')}>
|
||||
{() => <TagInput includeNico includeUnused value={draft.get ('tags') ?? ''}
|
||||
setValue={value => update ('tags', value)}/>}
|
||||
</FormField>}
|
||||
<CountsAdditionalConditions resource={resource} params={draft} update={update}/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ const CountsFieldTree = (
|
||||
params: URLSearchParams
|
||||
update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label } = useUITranslation ('counts')
|
||||
const fields = suppliedFields ?? fieldsForCounts (resource)
|
||||
const roots = suppliedFields == null ? selectedCountColumns (resource, params)
|
||||
: selectedCountFields (fields, params, selectionKey, parent).map (field => field.key)
|
||||
@@ -151,7 +150,6 @@ const CountsFieldTree = (
|
||||
<>
|
||||
<FieldGroup fields={fields} path={parent} params={params} enabled selected={selected}
|
||||
select={select} update={update}/>
|
||||
{suppliedFields == null && <p className="mt-2 text-xs">{label ('nested.selection')}</p>}
|
||||
</>)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ const CountsNestedTable = (
|
||||
const scalar = (row: CountRecord, field: string) => {
|
||||
const value = row[field]
|
||||
if (value == null) return null
|
||||
if (field === 'tagId' || field === 'tagGroupId')
|
||||
return <PrefetchLink to={field === 'tagId'
|
||||
? `/tags/${encodeURIComponent (String (value))}` : '/wiki'}>
|
||||
{String (value)}</PrefetchLink>
|
||||
if (field === 'id')
|
||||
{
|
||||
if (row.kind === 'external') return String (value)
|
||||
|
||||
@@ -7,12 +7,12 @@ import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import PostThumbnailPreview from '@/components/posts/PostThumbnailPreview'
|
||||
import { changeCountSort, countColumns, countsRequestParams, fieldsForCounts,
|
||||
readCountSort, selectedCountColumns, writeCountSort } from '@/lib/counts'
|
||||
import { countsCellCopy, countsCellText, countsImageURL } from '@/lib/countsDisplay'
|
||||
import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows } from '@/lib/countsDisplay'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountColumn, CountRecord, CountsResource,
|
||||
import type { CountColumn, CountsResource,
|
||||
CountsResult, CountsVersion } from '@/lib/counts'
|
||||
|
||||
|
||||
@@ -34,17 +34,23 @@ const CountsResults = (
|
||||
update ('sort', writeCountSort (changeCountSort (sorts, key, additive)))
|
||||
const cell = (row: CountsVersion, column: CountColumn) => {
|
||||
const field = fields.find (field => field.key === column)!
|
||||
const value = countsCellText (row, column, locale)
|
||||
const value = countsCellText (row, column, locale, params)
|
||||
if (field.children != null)
|
||||
{
|
||||
return <CountsNestedTable rows={row[column as keyof CountsVersion] as CountRecord[] ?? []}
|
||||
return <CountsNestedTable rows={countsNestedRows (row, column)}
|
||||
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)}`}>
|
||||
if (resource === 'nico_tags') return <PrefetchLink to="/nico/tags">
|
||||
{row.resourceId}</PrefetchLink>
|
||||
if (resource === 'tag_groups') return <PrefetchLink
|
||||
to={`/wiki/${encodeURIComponent (`タググループ:${row.name ?? ''}`)}`}>
|
||||
{row.resourceId}</PrefetchLink>
|
||||
return <PrefetchLink to={`/${resource === 'tag_descriptions' ? 'tags' : resource}/${
|
||||
encodeURIComponent (row.resourceId)}`}>
|
||||
{row.resourceId}</PrefetchLink>
|
||||
case 'thumbnail':
|
||||
{
|
||||
|
||||
@@ -1,62 +1,98 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||
import { countsSummaryCell, countsSummaryDrill, countsSummaryRows,
|
||||
selectedCountsSummaryFields } from '@/lib/countsState'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { countsFieldLabel, readCountsJSON } from '@/lib/countsDefinitions'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
import type { CountsResource, CountsResult } from '@/lib/counts'
|
||||
import type { CountsAxis } from '@/lib/countsDefinitions'
|
||||
|
||||
|
||||
const CountsSummaryResults = (
|
||||
{ resource, data, params, pending, update }: {
|
||||
resource: CountsResource
|
||||
data: CountsResult
|
||||
params: URLSearchParams
|
||||
pending: boolean
|
||||
update: (key: string, value: string) => void },
|
||||
resource: CountsResource; data: CountsResult; params: URLSearchParams
|
||||
pending: boolean; update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const locale = useLocale ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const fields = selectedCountsSummaryFields (resource, params)
|
||||
const rows = countsSummaryRows (data)
|
||||
const sorts = readCountSort (params.get ('sort') ?? (
|
||||
params.get ('axis') === 'tags' ? 'count:desc' : 'group:asc'))
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
{fields.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={`summary_${params.get ('axis')}`}
|
||||
columns={fields.map (field => field.key)}
|
||||
identity={`${countsRequestParams (params)}:${fields.map (field => field.key)}`}
|
||||
className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20"><tr>
|
||||
{fields.map (field => (
|
||||
<CountsSortHeader key={field.key} field={field.key}
|
||||
label={message (field.label)}
|
||||
sorts={sorts} change={(key, additive) => update (
|
||||
'sort', writeCountSort (changeCountSort (sorts, key, additive)))}/>))}
|
||||
</tr></thead>
|
||||
<tbody>{rows.map (row => (
|
||||
<tr key={String (row.key)}>
|
||||
{fields.map (field => {
|
||||
const value = countsSummaryCell (row, field.key, locale)
|
||||
const content = value.language == null ? value.text : (
|
||||
<LocalisedText language={value.language}>{value.text}</LocalisedText>)
|
||||
return <td key={field.key} data-counts-copy={value.text}
|
||||
className="break-words border-b px-2 py-1 dark:border-slate-700">
|
||||
{(['count', 'group'].includes (field.key) && row.count != null)
|
||||
? <PrefetchLink to={countsSummaryDrill (resource, params, row)}>
|
||||
{content}</PrefetchLink>
|
||||
: content}
|
||||
</td>
|
||||
})}
|
||||
</tr>))}</tbody>
|
||||
</CountsSelectableTable>)}
|
||||
{rows.length === 0 && <p className="p-2">{label ('empty')}</p>}
|
||||
</div>)
|
||||
const rows = data.rows ?? []
|
||||
const cols = data.cols ?? []
|
||||
const axes = [...rows, ...cols]
|
||||
const cells = data.cells ?? []
|
||||
const rowKeys = [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (0, rows.length))))]
|
||||
const columnKeys = data.columnValues?.map (values => JSON.stringify (values))
|
||||
?? [...new Set (cells.map (cell => JSON.stringify (cell.values.slice (rows.length))))]
|
||||
if (cols.length === 0 && columnKeys.length === 0) columnKeys.push ('[]')
|
||||
const sorts = readCountSort (params.get ('sort'))
|
||||
const display = (value: string | number | null, axis: CountsAxis, index: number): string => {
|
||||
if (value == null) return ''
|
||||
const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
|
||||
if (logical != null) return logical
|
||||
if (['tags', 'user', 'language_code', 'source_language_code'].includes (axis.field))
|
||||
return message ('notRegistered').text
|
||||
if (axis.field === 'changed_field') return message (countsFieldLabel (String (value))).text
|
||||
if (axis.field === 'event_type') return message (`events.${value}`).text
|
||||
if (axis.field === 'category' || axis.field === 'tag_category')
|
||||
return message (`common:categories.${value}`).text
|
||||
return String (value)
|
||||
}
|
||||
const drill = (values: (string | number | null)[]) => {
|
||||
const next = new URLSearchParams (params)
|
||||
const existing = readCountsJSON<unknown[]> (params, 'drill', [])
|
||||
next.set ('drill', JSON.stringify ([...existing,
|
||||
...axes.map ((axis, index) => ({ ...axis, value: values[index] }))]))
|
||||
next.set ('mode', 'details')
|
||||
next.delete ('sort')
|
||||
next.delete ('page')
|
||||
return `/counts/${resource}?${next}`
|
||||
}
|
||||
const keys = [...rows.map ((_, index) => `a${index}`),
|
||||
...columnKeys.map ((_, index) => `cell${index}`)]
|
||||
return <div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
<CountsSelectableTable family={`pivot_${resource}`} columns={keys}
|
||||
identity={`${countsRequestParams (params)}:${keys}`} className="text-left text-sm">
|
||||
<thead className="sticky top-0 z-20"><tr>
|
||||
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
|
||||
label={message (countsFieldLabel (axis.field))} sorts={sorts}
|
||||
change={(key, additive) => update ('sort', writeCountSort (
|
||||
changeCountSort (sorts, key, additive)))}/>)}
|
||||
{columnKeys.map ((key, index) => {
|
||||
const values = JSON.parse (key) as (string | number | null)[]
|
||||
const name = values.map ((value, position) =>
|
||||
`${message (countsFieldLabel (cols[position].field)).text}: ${
|
||||
display (value, cols[position], rows.length + position)}`).join (' / ')
|
||||
const sortKey = cols.length === 0 ? 'count' : `measure_${[
|
||||
...new TextEncoder ().encode (key)].map (byte =>
|
||||
byte.toString (16).padStart (2, '0')).join ('')}`
|
||||
return <CountsSortHeader key={key} field={`cell${index}`}
|
||||
label={{ text: name || message ('count').text, language: message ('count').language }}
|
||||
sorts={sorts.map (sort => sort.key === sortKey
|
||||
? { ...sort, key: `cell${index}` } : sort)}
|
||||
change={(_, additive) => update ('sort', writeCountSort (
|
||||
changeCountSort (sorts, sortKey, additive)))}/>
|
||||
})}
|
||||
</tr></thead>
|
||||
<tbody>{rowKeys.map (rowKey => {
|
||||
const rowValues = JSON.parse (rowKey) as (string | number | null)[]
|
||||
return <tr key={rowKey}>
|
||||
{rowValues.map ((value, index) => <td key={index}
|
||||
className="overflow-hidden break-words border px-2 py-1 dark:border-slate-700"
|
||||
data-counts-copy={display (value, rows[index], index)}>
|
||||
{display (value, rows[index], index)}</td>)}
|
||||
{columnKeys.map (columnKey => {
|
||||
const columnValues = JSON.parse (columnKey) as (string | number | null)[]
|
||||
const values = [...rowValues, ...columnValues]
|
||||
const cell = cells.find (cell => JSON.stringify (cell.values) === JSON.stringify (values))
|
||||
return <td key={columnKey} data-counts-copy={String (cell?.count ?? 0)}
|
||||
className="border px-2 py-1 dark:border-slate-700">
|
||||
<PrefetchLink to={drill (values)}>{cell?.count ?? 0}</PrefetchLink>
|
||||
</td>
|
||||
})}
|
||||
</tr>
|
||||
})}</tbody>
|
||||
</CountsSelectableTable>
|
||||
{cells.length === 0 && <p>{label ('empty')}</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする