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