このコミットが含まれているのは:
@@ -9,6 +9,14 @@ class CountsExportsController < ApplicationController
|
||||
token = CountsExportDownload.prepare(exporter, session[:counts_export_owner])
|
||||
response.headers['Cache-Control'] = 'private, no-store'
|
||||
render json: { token: }
|
||||
rescue CountsExportDownload::Busy
|
||||
head :too_many_requests
|
||||
rescue CountsExportDownload::TooLarge
|
||||
head 413
|
||||
rescue CountsExportDownload::TimedOut
|
||||
head :gateway_timeout
|
||||
rescue CountsExportDownload::StorageFull, Errno::ENOSPC
|
||||
head :insufficient_storage
|
||||
rescue ArgumentError, TypeError, KeyError, JSON::ParserError
|
||||
head :bad_request
|
||||
end
|
||||
|
||||
@@ -4,23 +4,70 @@ require 'fileutils'
|
||||
# Finish generation before reporting readiness; downloads never hold the full file in RAM.
|
||||
class CountsExportDownload
|
||||
EXPIRY = 10.minutes
|
||||
MAX_CONCURRENT_EXPORTS = 2
|
||||
MAX_FILE_BYTES = 128.megabytes
|
||||
MAX_STORED_BYTES = 512.megabytes
|
||||
MAX_EXPORT_SECONDS = 120
|
||||
class Busy < StandardError; end
|
||||
class TooLarge < StandardError; end
|
||||
class TimedOut < StandardError; end
|
||||
class StorageFull < StandardError; end
|
||||
|
||||
def self.directory = Rails.root.join('tmp', 'counts_exports')
|
||||
def self.verifier = Rails.application.message_verifier('counts_export_download')
|
||||
|
||||
def self.prepare(exporter, owner)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
slot = acquire_slot
|
||||
|
||||
begin
|
||||
generate(exporter, owner)
|
||||
ensure
|
||||
slot.close
|
||||
end
|
||||
end
|
||||
|
||||
def self.acquire_slot
|
||||
MAX_CONCURRENT_EXPORTS.times do |index|
|
||||
path = directory.join(".slot-#{ index }.lock")
|
||||
handle = File.open(path, File::RDWR | File::CREAT, 0o600)
|
||||
return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
|
||||
|
||||
handle.close
|
||||
end
|
||||
raise Busy
|
||||
end
|
||||
|
||||
def self.generate(exporter, owner)
|
||||
FileUtils.mkdir_p(directory, mode: 0o700)
|
||||
Dir.glob(directory.join('counts-*.export')).each do |path|
|
||||
File.unlink(path) if File.mtime(path) < Time.current - EXPIRY
|
||||
rescue Errno::ENOENT
|
||||
# Another request may have removed an expired download.
|
||||
end
|
||||
stored = Dir.glob(directory.join('counts-*')).sum do |path|
|
||||
File.size(path)
|
||||
rescue Errno::ENOENT
|
||||
0
|
||||
end
|
||||
reserved = MAX_CONCURRENT_EXPORTS * MAX_FILE_BYTES
|
||||
raise StorageFull if stored + reserved > MAX_STORED_BYTES
|
||||
|
||||
file = Tempfile.create(['counts-', '.part'], directory)
|
||||
complete = false
|
||||
bytes = 0
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + MAX_EXPORT_SECONDS
|
||||
path = file.path
|
||||
begin
|
||||
file.binmode
|
||||
exporter.body.each { file.write(_1) }
|
||||
exporter.body.each do |chunk|
|
||||
raise TimedOut if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
||||
|
||||
bytes += chunk.bytesize
|
||||
raise TooLarge if bytes > MAX_FILE_BYTES
|
||||
|
||||
file.write(chunk)
|
||||
end
|
||||
file.close
|
||||
finished_path = path.sub(/\.part\z/, '.export')
|
||||
File.rename(path, finished_path)
|
||||
|
||||
@@ -9,9 +9,9 @@ import type { ReactNode } from 'react'
|
||||
type Name = { text: string; language: string }
|
||||
|
||||
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup }: {
|
||||
const SortableItem = ({ id, name, fixed, children, moveGroup, handleId }: {
|
||||
id: string; name: Name; fixed?: boolean; children: ReactNode
|
||||
moveGroup: (direction: number) => void },
|
||||
moveGroup: (direction: number) => void; handleId: string },
|
||||
) => {
|
||||
const motion = useClientMotion ()
|
||||
const item = useSortable ({ id, disabled: fixed,
|
||||
@@ -25,15 +25,16 @@ const SortableItem = ({ id, name, fixed, children, moveGroup }: {
|
||||
item.isDragging && 'opacity-30 ring-2 ring-blue-500 dark:ring-blue-400')}>
|
||||
<div className="flex min-w-0 items-start gap-1 px-1">
|
||||
{fixed ? <span className="w-6 shrink-0"/> : <button type="button"
|
||||
ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||
id={handleId} ref={item.setActivatorNodeRef} {...item.attributes} {...item.listeners}
|
||||
aria-label={name.text} lang={name.language}
|
||||
aria-keyshortcuts="Space ArrowUp ArrowDown ArrowLeft ArrowRight Enter Escape"
|
||||
aria-keyshortcuts="Space ArrowUp ArrowDown Enter Escape Alt+ArrowLeft Alt+ArrowRight"
|
||||
className="mt-1 w-6 shrink-0 cursor-grab touch-none rounded
|
||||
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||
active:cursor-grabbing dark:focus-visible:ring-blue-400"
|
||||
onClick={event => event.stopPropagation ()}
|
||||
onKeyDown={event => {
|
||||
if (event.altKey && ['ArrowLeft', 'ArrowRight'].includes (event.key))
|
||||
if (event.altKey && !item.isDragging
|
||||
&& ['ArrowLeft', 'ArrowRight'].includes (event.key))
|
||||
{
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor,
|
||||
closestCenter, useDroppable, useSensor, useSensors } from '@dnd-kit/core'
|
||||
import { SortableContext, arrayMove, rectSortingStrategy,
|
||||
sortableKeyboardCoordinates } from '@dnd-kit/sortable'
|
||||
import { useState } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import SortableItem from '@/components/common/SortableItem'
|
||||
@@ -30,6 +30,9 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
onChange: (groups: Groups) => void; fixed?: string[] },
|
||||
) => {
|
||||
const [preview, setPreview] = useState<Groups | null> (null)
|
||||
const previewRef = useRef<Groups | null> (null)
|
||||
const lastOver = useRef<string | null> (null)
|
||||
const scope = useId ()
|
||||
const [active, setActive] = useState<string | null> (null)
|
||||
const motion = useClientMotion ()
|
||||
const current = preview ?? groups
|
||||
@@ -47,22 +50,33 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
if (source === target)
|
||||
{
|
||||
const from = value[source].indexOf (id)
|
||||
const to = over.startsWith ('group:') ? value[target].length - 1 : value[target].indexOf (over)
|
||||
const to = over.startsWith ('group:')
|
||||
? value[target].length - 1 : value[target].indexOf (over)
|
||||
if (from === to || to < 0) return value
|
||||
return { ...value, [source]: arrayMove (value[source], from, to) }
|
||||
}
|
||||
const destination = value[target].filter (item => item !== id)
|
||||
const position = destination.indexOf (over)
|
||||
destination.splice (position < 0 ? destination.length : position, 0, id)
|
||||
return { ...value, [source]: value[source].filter (item => item !== id), [target]: destination }
|
||||
return { ...value, [source]: value[source].filter (item => item !== id),
|
||||
[target]: destination }
|
||||
}
|
||||
const moveGroup = (id: string, direction: number) => {
|
||||
const keys = Object.keys (groups)
|
||||
const group = owner (id, groups)
|
||||
const target = keys[keys.indexOf (group ?? '') + direction]
|
||||
if (target != null) onChange (relocate (groups, id, `group:${target}`))
|
||||
if (target != null)
|
||||
{
|
||||
onChange (relocate (groups, id, `group:${target}`))
|
||||
requestAnimationFrame (() => document.getElementById (`${scope}-${id}`)?.focus ())
|
||||
}
|
||||
}
|
||||
const reset = () => {
|
||||
previewRef.current = null
|
||||
lastOver.current = null
|
||||
setPreview (null)
|
||||
setActive (null)
|
||||
}
|
||||
const reset = () => { setPreview (null); setActive (null) }
|
||||
return <DndContext sensors={sensors} collisionDetection={closestCenter}
|
||||
accessibility={{ announcements: {
|
||||
onDragStart: event => name (String (event.active.id)).text,
|
||||
@@ -72,19 +86,37 @@ const SortableList = ({ groups, name, renderItem, renderGroup, onChange, fixed =
|
||||
onDragMove: () => undefined, onDragEnd: () => undefined,
|
||||
onDragCancel: () => undefined },
|
||||
screenReaderInstructions: { draggable: active == null ? '' : name (active).text } }}
|
||||
onDragStart={event => { setPreview (groups); setActive (String (event.active.id)) }}
|
||||
onDragOver={event => {
|
||||
onDragStart={event => {
|
||||
previewRef.current = groups
|
||||
lastOver.current = null
|
||||
setPreview (groups)
|
||||
setActive (String (event.active.id))
|
||||
}} onDragOver={event => {
|
||||
if (event.over == null || event.active.id === event.over.id) return
|
||||
setPreview (previous => relocate (
|
||||
previous ?? groups, String (event.active.id), String (event.over!.id)))
|
||||
const target = String (event.over.id)
|
||||
if (lastOver.current === target) return
|
||||
const next = relocate (previewRef.current ?? groups, String (event.active.id), target)
|
||||
previewRef.current = next
|
||||
lastOver.current = target
|
||||
setPreview (next)
|
||||
}} onDragCancel={reset} onDragEnd={event => {
|
||||
if (event.over != null && preview != null) onChange (preview)
|
||||
if (event.over != null)
|
||||
{
|
||||
const id = String (event.active.id)
|
||||
const target = String (event.over.id)
|
||||
const latest = previewRef.current ?? groups
|
||||
const next = (target === id || target === lastOver.current)
|
||||
? latest : relocate (latest, id, target)
|
||||
onChange (next)
|
||||
}
|
||||
reset ()
|
||||
}}>
|
||||
|
||||
{Object.entries (current).map (([group, ids]) => <div key={group}>
|
||||
{renderGroup (group, <SortableContext items={ids} strategy={rectSortingStrategy}>
|
||||
<Group id={group}>{ids.map (id => <SortableItem key={id} id={id}
|
||||
name={name (id)} fixed={fixed.includes (id)} moveGroup={direction => moveGroup (id, direction)}>
|
||||
name={name (id)} fixed={fixed.includes (id)} handleId={`${scope}-${id}`}
|
||||
moveGroup={direction => moveGroup (id, direction)}>
|
||||
{renderItem (id, group)}
|
||||
</SortableItem>)}</Group>
|
||||
</SortableContext>)}
|
||||
|
||||
@@ -9,7 +9,7 @@ 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 { normaliseCountsParams } from '@/lib/counts'
|
||||
import { countsUseLatest, normaliseCountsParams } from '@/lib/counts'
|
||||
import { useClientMotion } from '@/lib/useClientMotion'
|
||||
import { inputClass } from '@/lib/utils'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
@@ -20,11 +20,13 @@ import type { CountsResource } from '@/lib/counts'
|
||||
|
||||
|
||||
const CountsControls = (
|
||||
{ resource, applied, open, setOpen, execute, displayJSON, reorder, actions }: {
|
||||
{ resource, applied, open, setOpen, execute, displayJSON, displayRowNumbers,
|
||||
reorder, actions }: {
|
||||
resource: CountsResource; applied: URLSearchParams; open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
execute: (params: URLSearchParams) => void
|
||||
displayJSON: (enabled: boolean) => void
|
||||
displayRowNumbers: (enabled: boolean) => void
|
||||
reorder: (value: string) => void; actions?: ReactNode },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
@@ -33,17 +35,19 @@ const CountsControls = (
|
||||
const [draft, setDraft] = useState (() => new URLSearchParams (applied))
|
||||
const order = applied.get ('order')
|
||||
const nestedJSON = applied.get ('nested_json')
|
||||
const rowNumbers = applied.get ('row_numbers')
|
||||
useEffect (() => {
|
||||
setDraft (previous => {
|
||||
const next = new URLSearchParams (previous)
|
||||
for (const [key, value] of Object.entries ({ order, nested_json: nestedJSON }))
|
||||
const display = { order, nested_json: nestedJSON, row_numbers: rowNumbers }
|
||||
for (const [key, value] of Object.entries (display))
|
||||
{
|
||||
if (value == null) next.delete (key)
|
||||
else next.set (key, value)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [order, nestedJSON])
|
||||
}, [order, nestedJSON, rowNumbers])
|
||||
const mode = draft.get ('mode') ?? 'summary'
|
||||
const history = draft.get ('definition') === 'history'
|
||||
const update = (key: string, value: string) => setDraft (previous => {
|
||||
@@ -66,13 +70,18 @@ const CountsControls = (
|
||||
{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">
|
||||
</> : <div 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}
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<input type="checkbox" checked={countsUseLatest (draft)}
|
||||
onChange={event => update ('current', event.target.checked ? '1' : '0')}/>
|
||||
{label ('latest')}</label>
|
||||
<DateTimeField precision="second" disabled={countsUseLatest (draft)}
|
||||
value={draft.get ('at') ?? ''}
|
||||
aria-label={message ('referenceAt').text} lang={message ('referenceAt').language}
|
||||
onChange={value => update ('at', value ?? '')}/>
|
||||
</label>}
|
||||
</div>}
|
||||
</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>
|
||||
@@ -87,7 +96,14 @@ const CountsControls = (
|
||||
value={mode} onChange={event => update ('mode', event.target.value)}>
|
||||
{['summary', 'details'].map (value => <option key={value} value={value}>
|
||||
{message (value).text}</option>)}
|
||||
</select>{actions}
|
||||
</select>
|
||||
<label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
|
||||
onChange={event => {
|
||||
update ('row_numbers', event.target.checked ? '1' : '0')
|
||||
displayRowNumbers (event.target.checked)
|
||||
}}/>{label ('rowNumbers')}
|
||||
</label>{actions}
|
||||
</div>
|
||||
<AnimatedCollapse id={id} open={open}>
|
||||
<div className="max-h-[35dvh] overflow-y-auto overscroll-contain
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import LocalisedText from '@/components/LocalisedText'
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||
import CountsNestedTable from '@/components/counts/CountsNestedTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||
@@ -7,7 +8,8 @@ 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, countsNestedRows } from '@/lib/countsDisplay'
|
||||
import { countsCellCopy, countsCellText, countsImageURL, countsNestedRows }
|
||||
from '@/lib/countsDisplay'
|
||||
import useDialogue from '@/lib/dialogues/useDialogue'
|
||||
import { useLocale } from '@/lib/useLocale'
|
||||
import { useCountsDefinition } from '@/lib/countsDefinitions'
|
||||
@@ -30,6 +32,8 @@ const CountsResults = (
|
||||
const locale = useLocale ()
|
||||
const dialogue = useDialogue ()
|
||||
const columns = selectedCountColumns (resource, params, definition)
|
||||
const rowNumbers = params.get ('row_numbers') === '1'
|
||||
const tableColumns = rowNumbers ? ['row_number', ...columns] : columns
|
||||
const fields = fieldsForCounts (resource, definition)
|
||||
const sorts = readCountSort (params.get ('sort') ?? 'created_at:desc')
|
||||
const sort = (key: string, additive: boolean) =>
|
||||
@@ -94,14 +98,15 @@ const CountsResults = (
|
||||
}
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto" data-counts-results aria-busy={pending}>
|
||||
{columns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
{tableColumns.length === 0 ? <p>{label ('nested.noColumns')}</p> : (
|
||||
<CountsSelectableTable family={resource}
|
||||
columns={columns}
|
||||
identity={`${resource}:${countsRequestParams (params)}:${columns.join (',')}:${
|
||||
columns={tableColumns}
|
||||
identity={`${resource}:${countsRequestParams (params)}:${tableColumns.join (',')}:${
|
||||
(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>
|
||||
{rowNumbers && <CountsRowNumber/>}
|
||||
{columns.map (column => column === 'thumbnail'
|
||||
? <th key={column} data-counts-heading={message (countColumns[column]).text}
|
||||
className="relative px-2 py-1">
|
||||
@@ -114,8 +119,10 @@ const CountsResults = (
|
||||
field => field.key === column)?.children != null}/>)}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{(data.versions ?? []).map (row => (
|
||||
{(data.versions ?? []).map ((row, index) => (
|
||||
<tr key={row.id} className="border-b dark:border-slate-700">
|
||||
{rowNumbers && <CountsRowNumber
|
||||
value={(data.page - 1) * data.limit + index + 1}/>}
|
||||
{columns.map (column => (
|
||||
<td key={column}
|
||||
className="overflow-hidden break-words border-b px-2 py-1
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||
import { useUITranslation } from '@/lib/useUITranslation'
|
||||
|
||||
|
||||
const CountsRowNumber = ({ value }: { value?: number }) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
if (value != null) return <td data-counts-copy={String (value)}
|
||||
className="border-b px-2 py-1 text-right align-top tabular-nums dark:border-slate-700">
|
||||
{value}</td>
|
||||
return <th data-counts-heading={message ('rowNumbers').text} className="relative px-2 py-1">
|
||||
{label ('rowNumbers')}
|
||||
<CountsResizeHandle field="row_number" label={message ('rowNumbers')}/>
|
||||
</th>
|
||||
}
|
||||
|
||||
|
||||
export default CountsRowNumber
|
||||
@@ -1,4 +1,5 @@
|
||||
import PrefetchLink from '@/components/PrefetchLink'
|
||||
import CountsRowNumber from '@/components/counts/CountsRowNumber'
|
||||
import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
|
||||
import CountsSortHeader from '@/components/counts/CountsSortHeader'
|
||||
import { changeCountSort, countsRequestParams, readCountSort, writeCountSort } from '@/lib/counts'
|
||||
@@ -15,11 +16,13 @@ const CountsSummaryResults = (
|
||||
pending: boolean; update: (key: string, value: string) => void },
|
||||
) => {
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const rowNumbers = params.get ('row_numbers') === '1'
|
||||
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 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 ('[]')
|
||||
@@ -46,12 +49,13 @@ const CountsSummaryResults = (
|
||||
next.delete ('page')
|
||||
return `/counts/${resource}?${next}`
|
||||
}
|
||||
const keys = [...rows.map ((_, index) => `a${index}`),
|
||||
const keys = [...(rowNumbers ? ['row_number'] : []), ...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>
|
||||
{rowNumbers && <CountsRowNumber/>}
|
||||
{rows.map ((axis, index) => <CountsSortHeader key={axis.field} field={`a${index}`}
|
||||
label={message (countsFieldLabel (axis.field))} sorts={sorts}
|
||||
change={(key, additive) => update ('sort', writeCountSort (
|
||||
@@ -65,16 +69,18 @@ const CountsSummaryResults = (
|
||||
...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 }}
|
||||
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 => {
|
||||
<tbody>{rowKeys.map ((rowKey, rowIndex) => {
|
||||
const rowValues = JSON.parse (rowKey) as (string | number | null)[]
|
||||
return <tr key={rowKey}>
|
||||
{rowNumbers && <CountsRowNumber value={(data.page - 1) * data.limit + rowIndex + 1}/>}
|
||||
{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)}>
|
||||
@@ -82,7 +88,8 @@ const CountsSummaryResults = (
|
||||
{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))
|
||||
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>
|
||||
|
||||
@@ -304,11 +304,18 @@ export const isCountsState = (params: URLSearchParams): boolean =>
|
||||
params.get ('definition') !== 'history'
|
||||
|
||||
|
||||
export const countsUseLatest = (params: URLSearchParams): boolean =>
|
||||
params.get ('current') === '1'
|
||||
|| (params.get ('current') !== '0' && !params.get ('at'))
|
||||
|
||||
|
||||
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||
const result = new URLSearchParams (params)
|
||||
for (const key of ['columns', 'summary_columns', 'children', 'child_sort',
|
||||
'nested_json', 'order', 'basis', 'axis', 'latest', 'grain'])
|
||||
'nested_json', 'order', 'basis', 'axis', 'latest', 'grain',
|
||||
'current', 'row_numbers'])
|
||||
result.delete (key)
|
||||
if (isCountsState (params) && countsUseLatest (params)) result.delete ('at')
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -331,10 +338,16 @@ export const exportCounts = (
|
||||
headers: string[],
|
||||
labels: Record<string, string>,
|
||||
onError?: () => void,
|
||||
): Promise<void> => apiDownload ('/counts/export', {
|
||||
...Object.fromEntries (params), resource, format,
|
||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||
labels: JSON.stringify (labels) }, locale, onError)
|
||||
): Promise<void> => {
|
||||
const payload = new URLSearchParams (params)
|
||||
payload.delete ('current')
|
||||
payload.delete ('row_numbers')
|
||||
if (isCountsState (params) && countsUseLatest (params)) payload.delete ('at')
|
||||
return apiDownload ('/counts/export', {
|
||||
...Object.fromEntries (payload), resource, format,
|
||||
columns: columns.join (','), headers: JSON.stringify (headers),
|
||||
labels: JSON.stringify (labels) }, locale, onError)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export const MIN_COUNT_WIDTH = 64
|
||||
export const MAX_COUNT_WIDTH = 2000
|
||||
|
||||
export const defaultCountWidths: Record<string, number> = {
|
||||
thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
||||
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
||||
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
||||
video_ms: 120, original_created_at: 260, original_created_from: 240,
|
||||
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useReducedMotion } from 'framer-motion'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { clientAnimationTransition, clientMotionTokens } from '@/lib/clientAnimation'
|
||||
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
@@ -7,7 +8,9 @@ import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
||||
export const useClientMotion = () => {
|
||||
const settings = useClientBehaviourSettings ()
|
||||
const osReduced = useReducedMotion ()
|
||||
const mode = osReduced ? 'reduced' : settings.animation ?? 'normal'
|
||||
const [hydrated, setHydrated] = useState (false)
|
||||
useEffect (() => setHydrated (true), [])
|
||||
const mode = hydrated ? (osReduced ? 'reduced' : settings.animation ?? 'normal') : 'normal'
|
||||
const reduced = mode !== 'normal'
|
||||
const duration = (kind: keyof typeof clientMotionTokens.durationMS) =>
|
||||
reduced ? 0 : clientMotionTokens.durationMS[kind]
|
||||
|
||||
@@ -217,5 +217,6 @@
|
||||
"children": "子項目",
|
||||
"profile": "出力設定",
|
||||
"path": "出力パス"
|
||||
}
|
||||
},
|
||||
"rowNumbers": "行番号"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
const controlParams = new URLSearchParams (params)
|
||||
controlParams.delete ('nested_json')
|
||||
controlParams.delete ('order')
|
||||
controlParams.delete ('row_numbers')
|
||||
const request = countsRequestParams (params)
|
||||
const [open, setOpen] = useState (false)
|
||||
const [sidebarOpen, setSidebarOpen] = useState (false)
|
||||
@@ -163,7 +164,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
<ul className="ml-3">
|
||||
{['latest', 'history'].map (definition => {
|
||||
const next = new URLSearchParams ({ definition, mode })
|
||||
for (const key of ['timezone', 'at'])
|
||||
for (const key of ['timezone', 'at', 'current'])
|
||||
if (params.get (key)) next.set (key, params.get (key)!)
|
||||
const active = target === resource
|
||||
&& params.get ('definition') === definition
|
||||
@@ -187,6 +188,7 @@ const CountsResourceContent = ({ resource }: { resource: CountsResource }) => {
|
||||
open={open} setOpen={setOpen}
|
||||
reorder={value => update ('order', value)}
|
||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||
displayRowNumbers={enabled => update ('row_numbers', enabled ? '1' : '0')}
|
||||
execute={next => {
|
||||
if (next.toString () === canonical) void refetch ()
|
||||
else setParams (next)
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする