このコミットが含まれているのは:
2026-10-27 12:39:32 +09:00
コミット 2706789fa4
10個のファイルの変更、94行の追加、30行の削除
+11
ファイルの表示
@@ -15,6 +15,17 @@
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
## Mandatory naming contract
- ルート `AGENTS.md` の必須命名規約を適用する。非 callable の自前識別子に
動詞を使わず、形容詞の状態名、名詞の真偽値には `Flg` を使用する。
例:`isLatest` は `latest`、`isAdmin` は `adminFlg`。
- React / TanStack Query 等の外部 API 名は維持する。取得した値を自前の状態に
保持するときは、必要に応じて分割代入等の境界で別名を付ける。
callable の state setter、event handler、callback は動詞名を使用できる。
- 型、表示項目キー、URL、API、コピー・エクスポートのフィールド参照を整合させる。
旧キーの互換性は共通の入出力境界で処理し、新しい内部状態には旧名を使用しない。
## Scope
These rules apply to work under `frontend/`.
+13 -7
ファイルの表示
@@ -22,7 +22,7 @@ import { initialiseUserSession } from '@/lib/users'
import { prepareHelpPage } from '@/lib/help'
import { fetchLocales, isLocaleCode, legacyLocalePath,
switchLocalePath, unlocalisedPath } from '@/lib/locales'
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
import { switchPostFilterLocale } from '@/lib/posts'
import { switchWikiLocale } from '@/lib/wiki'
import { useLocale } from '@/lib/useLocale'
@@ -63,7 +63,10 @@ const lazyPage = <Props extends object,> (
})
const Deferred = lazy (preload)
// A preloaded page must render synchronously on the first hydration pass.
const Page = (props: Props) => createElement (loaded ?? Deferred, props)
const Page = (props: Props) => {
const [Component] = useState (() => loaded ?? Deferred)
return createElement (Component, props)
}
return Object.assign (Page, { preload })
}
@@ -77,7 +80,6 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
const MaterialSyncSuppressionsPage = lazyPage (
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
@@ -196,8 +198,8 @@ export const pageRoutes = (
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
{ path: 'counts', element: <CountsPage/>,
handle: { preload: CountsPage.preload } },
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
handle: { preload: CountsResourcePage.preload } },
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
@@ -276,10 +278,14 @@ const RoutedApp = ({ locales }: { locales: AvailableLocale[] }) => {
const next = typeof action === 'function' ? action (authority.current) : action
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
{
client.cancelQueries ()
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
const filters = {
predicate: (query: { queryKey: readonly unknown[] }) =>
isAuthorityDependentQuery (query.queryKey) }
void client.cancelQueries (filters)
client.removeQueries (filters)
}
authority.current = next
client.setQueryData (sessionKeys.current, next)
setUserState (next)
}, [client])
const [status, setStatus] = useState (200)
+2 -1
ファイルの表示
@@ -61,7 +61,8 @@ export type CountsPivotHeader = { values: (string | number | null)[]
kind: 'data' | 'subtotal' | 'grand_total' }
export type CountsResult = {
populationCount?: number
excludedCount?: number
excludedCount?: number | null
excludedCountScope?: string
outputCount?: number | null
visibleOutputCount?: number
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
+6
ファイルの表示
@@ -14,6 +14,12 @@ export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
export const countsResources: CountsResource[] = [
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
// Navigation visibility only; supported resources and direct URLs remain available.
// The external-tag entry currently uses Nico tag history.
export const visibleCountsResources = [
'posts', 'tags', 'nico_tags'] as const satisfies readonly CountsResource[]
export type CountsFieldMetadata = {
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
labels?: string[]; multiple?: boolean }
+9
ファイルの表示
@@ -157,6 +157,15 @@ export const sessionKeys = {
settings: (id: number) => ['session', id, 'settings'] as const,
themeSlots: (id: number) => ['session', id, 'themeSlots'] as const }
export const isAuthorityDependentQuery = (key: readonly unknown[]): boolean => {
// Only the public locale catalogue and the explicitly synchronised session survive.
const publicLocaleFlg = key.length === 2
&& key[0] === localesKeys.index[0] && key[1] === localesKeys.index[1]
const currentSessionFlg = key.length === 2
&& key[0] === sessionKeys.current[0] && key[1] === sessionKeys.current[1]
return !publicLocaleFlg && !currentSessionFlg
}
export const theatresKeys = {
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
programmes: (id: string, locale: LocaleCode) =>
+2 -2
ファイルの表示
@@ -147,7 +147,7 @@
"resources": {
"posts": "広場",
"tags": "タグ",
"nico_tags": "ニコニコタグ",
"nico_tags": "外部タグ",
"tag_descriptions": "タグ説明",
"tag_groups": "タググループ",
"materials": "素材"
@@ -172,7 +172,7 @@
"changedField": "変更項目",
"eventType": "異動区分",
"count": "件数",
"outputTotal": "綜件数:{{value}} 件",
"outputTotal": "件数:{{value}} 件",
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
"recordTotal": "閲覧可能件数:{{count}}",
"grain": "粒度",
+11 -15
ファイルの表示
@@ -19,7 +19,7 @@ import { countsRequestParams, countsDefinitionParams, countsExportPayload, field
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsExportLabels } from '@/lib/countsDisplay'
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
countsResources, countsFieldLabel,
visibleCountsResources, countsFieldLabel,
fetchCountsDefinition }
from '@/lib/countsDefinitions'
import { countsKeys } from '@/lib/queryKeys'
@@ -211,9 +211,6 @@ const CountsResourceContent = (
const waiting = running || (!definitionError && (definition == null
|| isPending || isFetching || isPlaceholderData
|| (!params.has ('execution_at') && !isError)))
const loadingParams = pendingRun.current?.params ?? params
const compatible = data != null && ['definition', 'mode', 'rows', 'cols'].every (
key => loadingParams.get (key) === resultParams.get (key))
const update = (key: string, value: string, extra?: Record<string, string>) => {
@@ -287,7 +284,7 @@ const CountsResourceContent = (
lang={message ('target').language}>
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
<ul className="space-y-1">
{countsResources.map (target => <li key={target}>
{visibleCountsResources.map (target => <li key={target}>
<button type="button" className="flex w-full items-center gap-1 py-1 text-left
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
dark:focus-visible:ring-blue-400"
@@ -352,16 +349,15 @@ const CountsResourceContent = (
{(definitionError || isError || 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} aria-hidden={waiting || undefined}
className={cn ('flex min-h-0 flex-1 flex-col',
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
{definition != null && data != null && <>
{data.excludedCount != null && (
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{label ('excludedRecords', { count: data.excludedCount })}</span>
</p>)}
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
{data.excludedCountScope === 'current_query'
&& data.excludedCount != null && data.excludedCount > 0 && (
<span className="text-xs text-slate-500 dark:text-slate-400">
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
</p>
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
data={data} params={resultParams}
pending={waiting} update={update}/> : (
@@ -372,7 +368,7 @@ const CountsResourceContent = (
</>}
</div>
{waiting && <div role="status" aria-live="polite"
className="pointer-events-none absolute inset-0 flex items-center justify-center">
className="pointer-events-none absolute right-2 top-2">
<span className="rounded border border-slate-300 bg-white px-2 py-1
text-sm text-slate-900 dark:border-slate-600
dark:bg-slate-950 dark:text-slate-100">