このコミットが含まれているのは:
+4
-1
@@ -11,7 +11,10 @@
|
||||
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
|
||||
サーバーが確定した current_user の role のみを認可に使用する。
|
||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
|
||||
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
|
||||
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
|
||||
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
|
||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const CountsControls = (
|
||||
const mode = draft.get ('mode') ?? 'summary'
|
||||
const columns = selectedCountColumns (resource, draft, definition)
|
||||
const details = mode === 'details'
|
||||
const hasNested = details && fieldsForCounts (resource, definition).some (field =>
|
||||
const nestedFlg = details && fieldsForCounts (resource, definition).some (field =>
|
||||
columns.includes (field.key) && field.children != null
|
||||
&& !countsNestedIsScalar (field.children, field.key, draft)
|
||||
&& field.children.some (child => selectedCountChildren (draft)
|
||||
@@ -163,7 +163,7 @@ const CountsControls = (
|
||||
onChange={event => displayThumbnail (event.target.checked)}/>
|
||||
{label ('columns.thumbnailDisplay')}
|
||||
</label>}
|
||||
{hasNested && <label className="inline-flex items-center gap-1 text-sm">
|
||||
{nestedFlg && <label className="inline-flex items-center gap-1 text-sm">
|
||||
<input type="checkbox" checked={draft.get ('nested_json') === '1'}
|
||||
onChange={event => {
|
||||
update ('nested_json', event.target.checked ? '1' : '0')
|
||||
|
||||
@@ -87,7 +87,7 @@ const CountsResults = (
|
||||
case 'thumbnail_base':
|
||||
return <a href={countsImageURL (value) ?? undefined} target="_blank" rel="noreferrer">
|
||||
{value}</a>
|
||||
case 'is_latest':
|
||||
case 'latest':
|
||||
return <LocalisedText language={message ('latestValue').language}>
|
||||
{value}</LocalisedText>
|
||||
case 'user':
|
||||
|
||||
@@ -53,6 +53,7 @@ const CountsSelectableTable = (
|
||||
) => {
|
||||
const id = useId ()
|
||||
const root = useRef<HTMLTableElement> (null)
|
||||
const textCell = useRef<HTMLTableCellElement | null> (null)
|
||||
const dragging = useRef (false)
|
||||
const touchArmed = useRef (false)
|
||||
const touchPress = useRef<{ x: number; y: number
|
||||
@@ -64,6 +65,15 @@ const CountsSelectableTable = (
|
||||
const selection = stored?.identity === identity ? stored : null
|
||||
const layout = useCountsTableLayout ()
|
||||
const { label, message } = useUITranslation ('counts')
|
||||
const clearTextSelection = useCallback (() => {
|
||||
const cell = textCell.current
|
||||
if (cell == null) return
|
||||
delete cell.dataset.countsText
|
||||
const text = window.getSelection ()
|
||||
if (text != null && cell.contains (text.anchorNode)) text.removeAllRanges ()
|
||||
textCell.current = null
|
||||
}, [])
|
||||
useEffect (() => () => clearTextSelection (), [identity, clearTextSelection])
|
||||
const bounds = useMemo (() => selection == null ? null : {
|
||||
top: Math.min (selection.anchor.row, selection.end.row),
|
||||
bottom: Math.max (selection.anchor.row, selection.end.row),
|
||||
@@ -198,6 +208,14 @@ const CountsSelectableTable = (
|
||||
closeMenu ()
|
||||
}
|
||||
const copy = (event: ClipboardEvent<HTMLTableElement>) => {
|
||||
const text = window.getSelection ()
|
||||
if (textCell.current != null && text != null && !text.isCollapsed
|
||||
&& textCell.current.contains (text.anchorNode)
|
||||
&& textCell.current.contains (text.focusNode))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
}
|
||||
if (document.activeElement !== root.current) return
|
||||
const payload = clipboard ()
|
||||
if (payload == null) return
|
||||
@@ -232,6 +250,26 @@ const CountsSelectableTable = (
|
||||
'[&>tbody>tr>td[data-counts-selected]]:outline-blue-500',
|
||||
'[&>tbody>tr>td[data-counts-selected]]:outline-offset-[-1px]', className)}
|
||||
onCopy={copy}
|
||||
onDoubleClick={event => {
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget
|
||||
|| element.closest (interactive) != null) return
|
||||
const cell = element.closest ('td')
|
||||
if (cell == null) return
|
||||
event.preventDefault ()
|
||||
event.stopPropagation ()
|
||||
finish ()
|
||||
clearTextSelection ()
|
||||
setStored (null)
|
||||
setMenu (null)
|
||||
textCell.current = cell
|
||||
cell.dataset.countsText = ''
|
||||
const range = document.createRange ()
|
||||
range.selectNodeContents (cell)
|
||||
const text = window.getSelection ()
|
||||
text?.removeAllRanges ()
|
||||
text?.addRange (range)
|
||||
}}
|
||||
onClickCapture={event => {
|
||||
if ((event.target as Element).closest ('table') !== event.currentTarget
|
||||
|| !suppressClick.current) return
|
||||
@@ -243,6 +281,12 @@ const CountsSelectableTable = (
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget) return
|
||||
suppressClick.current = false
|
||||
if (textCell.current != null && textCell.current.contains (element))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
}
|
||||
clearTextSelection ()
|
||||
if (element.closest (interactive) != null || event.button !== 0) return
|
||||
if (element instanceof HTMLElement && element.hasAttribute ('data-counts-scroll'))
|
||||
{
|
||||
@@ -300,6 +344,11 @@ const CountsSelectableTable = (
|
||||
onContextMenu={event => {
|
||||
const element = event.target as Element
|
||||
if (element.closest ('table') !== event.currentTarget) return
|
||||
if (textCell.current != null && textCell.current.contains (element))
|
||||
{
|
||||
event.stopPropagation ()
|
||||
return
|
||||
}
|
||||
const point = pointFor (element, event.clientY)
|
||||
if (point == null || element.closest ('[data-counts-resize]') != null) return
|
||||
event.preventDefault ()
|
||||
@@ -321,9 +370,11 @@ const CountsSelectableTable = (
|
||||
}
|
||||
if (event.key === 'Escape')
|
||||
{
|
||||
clearTextSelection ()
|
||||
setStored (null)
|
||||
return
|
||||
}
|
||||
if (textCell.current != null) return
|
||||
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes (event.key)) return
|
||||
const rows = event.currentTarget.tBodies[0]?.rows
|
||||
if (rows == null || rows.length === 0) return
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
@apply h-6;
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr > td[data-counts-text] {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.counts-grid > tbody > tr:nth-child(odd) > td:not([data-counts-selected]) {
|
||||
@apply bg-white text-slate-900;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export type CountsVersion = {
|
||||
id: number
|
||||
resourceId: number
|
||||
versionNo: number
|
||||
isLatest: boolean
|
||||
latest: boolean
|
||||
createdAt: string
|
||||
createdByUser: { id: number; name: string | null } | null
|
||||
title?: string | null
|
||||
@@ -98,7 +98,7 @@ export type CountRecord = Record<string, unknown>
|
||||
export const countColumns: Record<string, string> = {
|
||||
event_type: 'counts:eventType',
|
||||
thumbnail: 'counts:columns.thumbnailDisplay', resource_id: 'counts:columns.resourceId',
|
||||
version_no: 'counts:columns.version', is_latest: 'counts:columns.latest',
|
||||
version_no: 'counts:columns.version', latest: 'counts:columns.latest',
|
||||
title: 'counts:columns.postTitle', url: 'counts:columns.postURL',
|
||||
thumbnail_base: 'counts:columns.thumbnailURL', tags: 'counts:columns.tags',
|
||||
parents: 'shared:labels.parentPosts', video_ms: 'shared:labels.duration',
|
||||
@@ -155,7 +155,7 @@ export const columnsForCounts = (
|
||||
definition?: CountsDefinition,
|
||||
): CountColumn[] => definition?.outputFields?.map (field => field.key) ?? [
|
||||
...(resource === 'posts' ? ['thumbnail'] : []),
|
||||
'resource_id', 'version_no', 'is_latest',
|
||||
'resource_id', 'version_no', 'latest',
|
||||
...(resource === 'posts'
|
||||
? ['title', 'url', 'thumbnail_base', 'tags', 'parents', 'video_ms',
|
||||
'original_created_from', 'original_created_before']
|
||||
@@ -326,6 +326,14 @@ export const normaliseCountsParams = (
|
||||
definition?: CountsDefinition,
|
||||
): URLSearchParams => {
|
||||
const params = new URLSearchParams (source)
|
||||
for (const [key, value] of params)
|
||||
{
|
||||
if (['columns', 'order', 'sort', 'rows', 'cols', 'axis'].includes (key))
|
||||
params.set (key, value.split (' ').map (token =>
|
||||
token.replace (/^is_latest(?=:|$)/, 'latest')).join (' '))
|
||||
else if (/^(conditions|drill)\.\d+\.field$/.test (key) && value === 'is_latest')
|
||||
params.set (key, 'latest')
|
||||
}
|
||||
if (!params.has ('definition'))
|
||||
params.set ('definition', params.get ('basis') === 'history' ? 'history' : 'latest')
|
||||
if (params.get ('definition') === 'latest' && !params.has ('current'))
|
||||
|
||||
@@ -24,8 +24,8 @@ export const countsCellText = (
|
||||
{
|
||||
case 'resource_id': return String (row.resourceId)
|
||||
case 'version_no': return String (row.versionNo)
|
||||
case 'is_latest':
|
||||
return row.isLatest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
|
||||
case 'latest':
|
||||
return row.latest ? translateUIMessage ('latestValue', 'counts', locale).text : ''
|
||||
case 'title': return row.title ?? ''
|
||||
case 'url': return row.url ?? ''
|
||||
case 'thumbnail':
|
||||
|
||||
@@ -4,7 +4,7 @@ export const MIN_COUNT_WIDTH = 64
|
||||
export const MAX_COUNT_WIDTH = 2000
|
||||
|
||||
export const defaultCountWidths: Record<string, number> = {
|
||||
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, is_latest: 104,
|
||||
row_number: 72, thumbnail: 88, resource_id: 88, version_no: 88, latest: 104,
|
||||
title: 300, url: 300, thumbnail_base: 300, tags: 560, parents: 180,
|
||||
video_ms: 120, original_created_from: 240,
|
||||
original_created_before: 240, name: 240, names: 480, category: 180,
|
||||
|
||||
@@ -91,8 +91,8 @@ const CountsResourceContent = (
|
||||
const [exporting, setExporting] = useState (false)
|
||||
const exportInFlight = useRef (false)
|
||||
const [exportError, setExportError] = useState (false)
|
||||
const { data, dataUpdatedAt, isFetching, isPending, isError,
|
||||
isPlaceholderData } = useQuery ({
|
||||
const { data, dataUpdatedAt, isFetching: fetching, isPending: pending, isError: errorFlg,
|
||||
isPlaceholderData: placeholder } = useQuery ({
|
||||
enabled: definition != null && params.has ('timezone') && !running,
|
||||
staleTime: params.has ('execution_at') ? 5 * 60 * 1000 : 0,
|
||||
queryKey: countsKeys.index (resource, cacheParams.toString (), locale),
|
||||
@@ -104,7 +104,7 @@ const CountsResourceContent = (
|
||||
|
||||
useEffect (() => {
|
||||
applied.current = params
|
||||
if (data != null && !isPlaceholderData && !isError) retainedParams.current = params
|
||||
if (data != null && !placeholder && !errorFlg) retainedParams.current = params
|
||||
const pending = pendingRun.current
|
||||
if (pending == null) return
|
||||
if (pending.target === canonical)
|
||||
@@ -118,7 +118,7 @@ const CountsResourceContent = (
|
||||
void queryClient.cancelQueries ({ queryKey: pending.key, exact: true })
|
||||
setRunning (false)
|
||||
}
|
||||
}, [canonical, data, isError, isPlaceholderData, params, queryClient, requestKey])
|
||||
}, [canonical, data, errorFlg, placeholder, params, queryClient, requestKey])
|
||||
|
||||
useEffect (() => {
|
||||
setRunning (false)
|
||||
@@ -132,7 +132,7 @@ const CountsResourceContent = (
|
||||
}, [locale, queryClient, resource])
|
||||
|
||||
useEffect (() => {
|
||||
if (running || data == null || isPlaceholderData || isFetching || isError
|
||||
if (running || data == null || placeholder || fetching || errorFlg
|
||||
|| !params.has ('timezone')) return
|
||||
const next = new URLSearchParams (canonical)
|
||||
if (data.executionAt != null && !params.has ('execution_at'))
|
||||
@@ -145,7 +145,7 @@ const CountsResourceContent = (
|
||||
}
|
||||
if (source.toString () !== next.toString ())
|
||||
setParams (next, { replace: true, preventScrollReset: true })
|
||||
}, [canonical, data, dataUpdatedAt, isError, isFetching, isPlaceholderData,
|
||||
}, [canonical, data, dataUpdatedAt, errorFlg, fetching, placeholder,
|
||||
locale, params, queryClient, resource, running, setParams, source])
|
||||
|
||||
|
||||
@@ -206,11 +206,11 @@ const CountsResourceContent = (
|
||||
setRunError (true)
|
||||
}
|
||||
}
|
||||
const resultParams = isPlaceholderData ? retainedParams.current : params
|
||||
const resultParams = placeholder ? retainedParams.current : params
|
||||
const resultMode = resultParams.get ('mode') ?? 'summary'
|
||||
const waiting = running || (!definitionError && (definition == null
|
||||
|| isPending || isFetching || isPlaceholderData
|
||||
|| (!params.has ('execution_at') && !isError)))
|
||||
|| pending || fetching || placeholder
|
||||
|| (!params.has ('execution_at') && !errorFlg)))
|
||||
|
||||
|
||||
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||
@@ -324,7 +324,7 @@ const CountsResourceContent = (
|
||||
style={{ minHeight: definition == null ? controlsHeight : undefined }}>
|
||||
{definition != null && <CountsControls key={`${resource}:${params.get ('definition')}`}
|
||||
resource={resource} applied={params}
|
||||
pending={running || isFetching || !params.has ('execution_at')}
|
||||
pending={running || fetching || !params.has ('execution_at')}
|
||||
open={open} setOpen={setOpen}
|
||||
reorder={value => update ('order', value)}
|
||||
displayJSON={enabled => update ('nested_json', enabled ? '1' : '0')}
|
||||
@@ -338,15 +338,15 @@ const CountsResourceContent = (
|
||||
actions={<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{(['csv', 'json'] as const).map (format => <Button key={format}
|
||||
type="button" variant="outline" size="sm"
|
||||
disabled={data == null || isError || isPlaceholderData
|
||||
|| running || isFetching || exporting || !params.get ('execution_at')}
|
||||
disabled={data == null || errorFlg || placeholder
|
||||
|| running || fetching || exporting || !params.get ('execution_at')}
|
||||
onClick={() => void download (format)}>
|
||||
{label (`exportFormats.${format}`)}</Button>)}
|
||||
</div>}/>}
|
||||
|
||||
{exportError && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
</div>
|
||||
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
{(definitionError || errorFlg || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
aria-busy={waiting}>
|
||||
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -382,11 +382,11 @@ const CountsResourceContent = (
|
||||
|
||||
|
||||
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
|
||||
const { data, isError } = useQuery ({
|
||||
const { data, isError: errorFlg } = useQuery ({
|
||||
queryKey: countsKeys.definition (resource),
|
||||
queryFn: () => fetchCountsDefinition (resource), staleTime: Infinity })
|
||||
return <CountsDefinitionContext.Provider value={data ?? null}>
|
||||
<CountsResourceContent resource={resource} definition={data} definitionError={isError}/>
|
||||
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/>
|
||||
</CountsDefinitionContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
新しいイシューから参照
ユーザーをブロックする