このコミットが含まれているのは:
@@ -102,6 +102,44 @@ export const fieldsForCounts = (resource: CountsResource): CountField[] =>
|
||||
children: nested[key] }))
|
||||
|
||||
|
||||
export const orderedCountFields = (
|
||||
fields: CountField[],
|
||||
params: URLSearchParams,
|
||||
parent = '',
|
||||
): CountField[] => {
|
||||
const prefix = parent ? `${parent}.` : ''
|
||||
const saved = (params.get ('order') ?? '').split (',')
|
||||
const keys = [...new Set ([...saved, ...fields.map (field => `${prefix}${field.key}`)])]
|
||||
const ordered = keys.flatMap (key => {
|
||||
const field = fields.find (field => `${prefix}${field.key}` === key)
|
||||
return field == null ? [] : [field]
|
||||
})
|
||||
return parent ? ordered : [
|
||||
...ordered.filter (field => field.key === 'thumbnail'),
|
||||
...ordered.filter (field => field.key !== 'thumbnail')]
|
||||
}
|
||||
|
||||
|
||||
export const reorderCountFields = (
|
||||
fields: CountField[],
|
||||
params: URLSearchParams,
|
||||
parent: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): string => {
|
||||
const ordered = orderedCountFields (fields, params, parent)
|
||||
const first = ordered.findIndex (field => field.key === from)
|
||||
const last = ordered.findIndex (field => field.key === to)
|
||||
if (first < 0 || last < 0) return params.get ('order') ?? ''
|
||||
const prefix = parent ? `${parent}.` : ''
|
||||
const current = ordered.map (field => `${prefix}${field.key}`)
|
||||
const next = [...current]
|
||||
next.splice (last, 0, ...next.splice (first, 1))
|
||||
return [...(params.get ('order') ?? '').split (',').filter (
|
||||
key => key && !current.includes (key)), ...next].join (',')
|
||||
}
|
||||
|
||||
|
||||
export const selectedCountColumns = (
|
||||
resource: CountsResource,
|
||||
params: URLSearchParams,
|
||||
@@ -110,7 +148,8 @@ export const selectedCountColumns = (
|
||||
columnsForCounts (resource).filter (key => ![
|
||||
'thumbnail', 'original_created_from', 'original_created_before']
|
||||
.includes (key))
|
||||
return columnsForCounts (resource).filter (key => selected.includes (key))
|
||||
return orderedCountFields (fieldsForCounts (resource), params)
|
||||
.map (field => field.key as CountColumn).filter (key => selected.includes (key))
|
||||
}
|
||||
|
||||
|
||||
@@ -164,14 +203,18 @@ export const normaliseCountsParams = (
|
||||
if (!['month', 'user'].includes (params.get ('axis') ?? ''))
|
||||
params.set ('axis', 'month')
|
||||
if (params.has ('columns'))
|
||||
params.set ('columns', selectedCountColumns (resource, params).join (','))
|
||||
{
|
||||
const selected = new Set (params.get ('columns')!.split (','))
|
||||
params.set ('columns', columnsForCounts (resource).filter (key => selected.has (key))
|
||||
.join (','))
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
|
||||
export const countsRequestParams = (params: URLSearchParams): URLSearchParams => {
|
||||
const result = new URLSearchParams (params)
|
||||
for (const key of ['columns', 'children', 'child_sort', 'nested_json'])
|
||||
for (const key of ['columns', 'children', 'child_sort', 'nested_json', 'order'])
|
||||
result.delete (key)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
export type CountsClipboard = { text: string; html: string }
|
||||
|
||||
|
||||
const escapeHTML = (value: string): string => value.replace (/[&<>"']/g, character =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!)
|
||||
|
||||
|
||||
export const countsClipboard = (rows: string[][], headers = false): CountsClipboard => {
|
||||
// Excel accepts quoted TSV fields only when delimiters or quotes require them.
|
||||
// Prefix formula-like text in both representations, including leading whitespace.
|
||||
const safe = rows.map (row => row.map (value => /^\s*[=+@-]/.test (value)
|
||||
? `'${value}` : value))
|
||||
const text = safe.map (row => row.map (value => /[\t\r\n"]/.test (value)
|
||||
? `"${value.replaceAll ('"', '""')}"` : value).join ('\t')).join ('\r\n')
|
||||
const html = '<html><body><table>' + safe.map ((row, index) => '<tr>' + row.map (value => {
|
||||
const tag = headers && index === 0 ? 'th' : 'td'
|
||||
// Preserve time strings, leading zeroes, and user text instead of inferring dates/formulae.
|
||||
const numeric = value.length <= 15 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test (value)
|
||||
const style = numeric ? 'white-space:pre-wrap' : 'white-space:pre-wrap;mso-number-format:"\\@"'
|
||||
return `<${tag} style="${escapeHTML (style)}">${escapeHTML (value)
|
||||
.replace (/\r\n|\r|\n/g, '<br>')}</${tag}>`
|
||||
}).join ('') + '</tr>').join ('') + '</table></body></html>'
|
||||
return { text, html }
|
||||
}
|
||||
|
||||
|
||||
export const writeCountsClipboard = async (payload: CountsClipboard): Promise<void> => {
|
||||
if (navigator.clipboard?.write != null && typeof ClipboardItem !== 'undefined')
|
||||
{
|
||||
try
|
||||
{
|
||||
await navigator.clipboard.write ([new ClipboardItem ({
|
||||
'text/plain': new Blob ([payload.text], { type: 'text/plain' }),
|
||||
'text/html': new Blob ([payload.html], { type: 'text/html' }) })])
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Browsers without HTML clipboard support may still permit plain TSV.
|
||||
}
|
||||
}
|
||||
if (navigator.clipboard?.writeText != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await navigator.clipboard.writeText (payload.text)
|
||||
return
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Try the synchronous copy event when clipboard permission is unavailable.
|
||||
}
|
||||
}
|
||||
// The synchronous copy event fallback is also used on non-secure local origins.
|
||||
const listener = (event: ClipboardEvent) => {
|
||||
event.preventDefault ()
|
||||
event.stopImmediatePropagation ()
|
||||
event.clipboardData?.setData ('text/plain', payload.text)
|
||||
event.clipboardData?.setData ('text/html', payload.html)
|
||||
}
|
||||
document.addEventListener ('copy', listener, true)
|
||||
try
|
||||
{
|
||||
if (!document.execCommand ('copy')) throw new Error ('Clipboard unavailable')
|
||||
}
|
||||
finally
|
||||
{
|
||||
document.removeEventListener ('copy', listener, true)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fieldsForCounts, selectedCountChildren, sortedCountChildren } from '@/lib/counts'
|
||||
import { fieldsForCounts, orderedCountFields, selectedCountChildren,
|
||||
sortedCountChildren } from '@/lib/counts'
|
||||
import { translateUIMessage } from '@/lib/i18n'
|
||||
import { localisedDateString, msToTime, originalCreatedAtString } from '@/lib/utils'
|
||||
|
||||
@@ -145,7 +146,8 @@ export const countsNestedExport = (
|
||||
locale: LocaleCode,
|
||||
): unknown => {
|
||||
const selected = selectedCountChildren (params)
|
||||
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||
const columns = orderedCountFields (fields, params, path)
|
||||
.filter (field => selected.has (`${path}.${field.key}`))
|
||||
if (columns.length === 0) return rows.map (() => ({ }))
|
||||
return countsNestedOrder (rows, path, params, locale).map (row =>
|
||||
Object.fromEntries (columns.map (field => [
|
||||
@@ -167,7 +169,8 @@ export const countsNestedText = (
|
||||
locale: LocaleCode,
|
||||
): string => {
|
||||
const selected = selectedCountChildren (params)
|
||||
const columns = fields.filter (field => selected.has (`${path}.${field.key}`))
|
||||
const columns = orderedCountFields (fields, params, path)
|
||||
.filter (field => selected.has (`${path}.${field.key}`))
|
||||
if (columns.length === 0)
|
||||
return `${translateUIMessage ('common:countLabel', 'counts', locale).text}: ${rows.length}`
|
||||
return countsNestedOrder (rows, path, params, locale).map (row => columns.map (field => {
|
||||
|
||||
@@ -41,7 +41,7 @@ type DialogueFormOptions = { title: ReactNode
|
||||
description?: ReactNode
|
||||
body: (controls: DialogueFormControls) => ReactNode
|
||||
cancelText?: ReactNode
|
||||
size?: 'default' | 'large' }
|
||||
size?: 'default' | 'large' | 'viewport' }
|
||||
|
||||
type DialogueAPI =
|
||||
{ confirm: (options: ConfirmOptions) => Promise<boolean>
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする