このコミットが含まれているのは:
2026-10-26 18:45:28 +09:00
コミット b4139f8370
13個のファイルの変更、199行の追加、45行の削除
+8
ファイルの表示
@@ -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
+48 -1
ファイルの表示
@@ -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)
+6 -5
ファイルの表示
@@ -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 ()
+43 -11
ファイルの表示
@@ -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>)}
+25 -9
ファイルの表示
@@ -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
+12 -5
ファイルの表示
@@ -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
+17
ファイルの表示
@@ -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
+12 -5
ファイルの表示
@@ -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>
+18 -5
ファイルの表示
@@ -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)
}
+1 -1
ファイルの表示
@@ -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,
+4 -1
ファイルの表示
@@ -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]
+2 -1
ファイルの表示
@@ -217,5 +217,6 @@
"children": "子項目", "children": "子項目",
"profile": "出力設定", "profile": "出力設定",
"path": "出力パス" "path": "出力パス"
} },
"rowNumbers": "行番号"
} }
+3 -1
ファイルの表示
@@ -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)