このコミットが含まれているのは:
2026-10-27 18:49:58 +09:00
コミット f9f31812a9
22個のファイルの変更、201行の追加、59行の削除
+4 -1
ファイルの表示
@@ -11,7 +11,10 @@
本文・属性・ID・版番号・関連参照・旧値を API / SSR / キャッシュ / 出力から漏らさない。
サーバーが確定した current_user の role のみを認可に使用する。
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
Counts では、現在の条件に対する権限除外前の出力行数と権限除外レコード数の
数値公開及びその差分による間接的な情報開示を許容する。これは件数だけの例外であり、
秘匿された本文・属性・識別情報・集計ラベルを公開する例外ではない。
それ以外の経路で秘匿属性を条件・軸・ソート・差分から推測できる実装は禁止する。
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
+2 -2
ファイルの表示
@@ -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')
+1 -1
ファイルの表示
@@ -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':
+51
ファイルの表示
@@ -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
+6
ファイルの表示
@@ -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;
}
+11 -3
ファイルの表示
@@ -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'))
+2 -2
ファイルの表示
@@ -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':
+1 -1
ファイルの表示
@@ -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,
+15 -15
ファイルの表示
@@ -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>
}