このコミットが含まれているのは:
@@ -13,6 +13,28 @@
|
|||||||
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
秘匿版も安全な全体件数には含めるが、公開明細・内訳・検索件数とは分離する。
|
||||||
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
秘匿属性を条件・軸・ソート・差分件数から推測できる実装を禁止する。
|
||||||
|
|
||||||
|
## Mandatory naming contract
|
||||||
|
|
||||||
|
- メソッド・関数など、実際に処理を実行する callable の名前以外の自前識別子に
|
||||||
|
動詞を使用しない。状態は名詞・形容詞・形容詞的な語で表す。今後の全開発に適用する。
|
||||||
|
- 変数、引数、プロパティ、型定義内の項目、API・URL パラメータ、JSON キー、
|
||||||
|
DB カラムにも適用する。`is_` / `is`、`has_` / `has`、`can_` / `can`、
|
||||||
|
`should_` / `should` などの動詞・助動詞由来の接頭辞を使用しない。
|
||||||
|
状態を格納する識別子に `show`、`enable` 等の動詞も使用しない。
|
||||||
|
- 形容詞で状態を表せる場合はその語を使う。例:`is_latest` / `isLatest` は
|
||||||
|
`latest`、`is_active` は `active`、`is_deleted` は `deleted`、
|
||||||
|
`is_visible` は `visible`、表示状態は `visible`、有効状態は `enabled`。
|
||||||
|
- 名詞で真偽値を表す場合は原則 `_flg`、camelCase では `Flg` を付ける。
|
||||||
|
例:`is_admin` は `admin_flg`、`is_owner` は `owner_flg`、
|
||||||
|
`isAdmin` は `adminFlg`。クエリパラメータでは意味が明確なら接尾辞を省略できる。
|
||||||
|
- callable の `setState`、`onChange` 等や、外部ライブラリ・フレームワーク所定の
|
||||||
|
API 名は変更しない。外部値を自前の状態へ保持する場合は境界で適切な別名を付ける。
|
||||||
|
言語固有の表記規約を維持し、予約語や曖昧さは規則を満たす明確な代替名で避ける。
|
||||||
|
- 改名時は Frontend / Backend、URL、API、出力の名称を整合させる。
|
||||||
|
旧 URL・API の互換性は入出力境界で扱い、旧命名を新しい内部コードへ伝播させない。
|
||||||
|
DB の改名は既存データを保持する追加マイグレーションで扱い、適用済みのものを
|
||||||
|
書き換えない。メソッド名・外部 API・文字列値等を機械的な一括置換で破壊しない。
|
||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
- 履歴読み取りは HistoryAccess の共通認可を必須とする。書込用 relation を公開しない。
|
||||||
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
|
export は生成時と取得時に認可し、権限変更・除票後に古いファイルを再利用しない。
|
||||||
|
|
||||||
|
## Mandatory naming contract
|
||||||
|
|
||||||
|
- ルート `AGENTS.md` の必須命名規約を適用する。メソッド等の callable 以外の
|
||||||
|
自前識別子に動詞を使わず、形容詞の状態名、名詞の真偽値には `_flg` を使用する。
|
||||||
|
例:`is_latest` は `latest`、`is_admin` は `admin_flg`。
|
||||||
|
- 引数、属性、SQL alias、API・URL パラメータ、JSON キー、DB カラムも対象とする。
|
||||||
|
Rails 等が定義する外部 API 名や、処理を実行するメソッド名は変更しない。
|
||||||
|
- 旧 API・URL の互換性は解析・シリアライズ境界で扱い、内部定義は新名に統一する。
|
||||||
|
DB カラムの改名はデータを保持する追加マイグレーションで設計し、適用済みの
|
||||||
|
マイグレーションを書き換えない。マイグレーションの実行には個別指示を要する。
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
These rules apply to work under `backend/`.
|
These rules apply to work under `backend/`.
|
||||||
|
|||||||
@@ -43,12 +43,9 @@ class CountsTableQuery
|
|||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
result = metadata.merge(mode == 'summary' ? summary : details)
|
result = metadata.merge(mode == 'summary' ? summary : details)
|
||||||
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
visible = mode == 'summary' ? result[:visible_output_count] : result[:count]
|
||||||
# A scalar result has one row regardless of private attributes or predicates.
|
|
||||||
public_count = result[:excluded_count].zero? || (mode == 'summary' && axes.rows.empty?)
|
|
||||||
result.merge(visible_output_count: visible,
|
result.merge(visible_output_count: visible,
|
||||||
output_count: public_count ? visible : nil,
|
output_count: visible,
|
||||||
output_count_scope: public_count ?
|
output_count_scope: 'authorised_results',
|
||||||
'before_authorisation' : 'unavailable_private_history',
|
|
||||||
output_count_unit: 'table_body_rows',
|
output_count_unit: 'table_body_rows',
|
||||||
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
page_grand_total_repeated: mode == 'summary' && axes.rows.any? &&
|
||||||
result[:group_count] > limit)
|
result[:group_count] > limit)
|
||||||
@@ -57,7 +54,12 @@ class CountsTableQuery
|
|||||||
|
|
||||||
def metadata
|
def metadata
|
||||||
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
@population_counts ||= HistoryAccess.population_counts(@definition.model)
|
||||||
|
# Global exclusions are not exclusions for this filtered query. Do not expose
|
||||||
|
# private predicate matches through a condition-dependent count.
|
||||||
|
exclusions_known = @population_counts[:excluded_count].zero?
|
||||||
@population_counts.merge(
|
@population_counts.merge(
|
||||||
|
excluded_count: exclusions_known ? 0 : nil,
|
||||||
|
excluded_count_scope: exclusions_known ? 'current_query' : 'unavailable_private_history',
|
||||||
population_count_scope: 'all_recorded_versions',
|
population_count_scope: 'all_recorded_versions',
|
||||||
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
page:, limit:, execution_at: population.at.iso8601(6), at: @params['at'].presence,
|
||||||
definition: population.history ? 'history' : 'latest',
|
definition: population.history ? 'history' : 'latest',
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
- 秘匿は React / CSS では実装しない。認可済み API のみを使う。
|
||||||
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、SSR の管理者データを共有しない。
|
認証・権限変更時は取得中リクエストと履歴キャッシュを破棄し、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
|
## Scope
|
||||||
|
|
||||||
These rules apply to work under `frontend/`.
|
These rules apply to work under `frontend/`.
|
||||||
|
|||||||
+13
-7
@@ -22,7 +22,7 @@ import { initialiseUserSession } from '@/lib/users'
|
|||||||
import { prepareHelpPage } from '@/lib/help'
|
import { prepareHelpPage } from '@/lib/help'
|
||||||
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
import { fetchLocales, isLocaleCode, legacyLocalePath,
|
||||||
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
switchLocalePath, unlocalisedPath } from '@/lib/locales'
|
||||||
import { localesKeys, sessionKeys } from '@/lib/queryKeys'
|
import { isAuthorityDependentQuery, localesKeys, sessionKeys } from '@/lib/queryKeys'
|
||||||
import { switchPostFilterLocale } from '@/lib/posts'
|
import { switchPostFilterLocale } from '@/lib/posts'
|
||||||
import { switchWikiLocale } from '@/lib/wiki'
|
import { switchWikiLocale } from '@/lib/wiki'
|
||||||
import { useLocale } from '@/lib/useLocale'
|
import { useLocale } from '@/lib/useLocale'
|
||||||
@@ -63,7 +63,10 @@ const lazyPage = <Props extends object,> (
|
|||||||
})
|
})
|
||||||
const Deferred = lazy (preload)
|
const Deferred = lazy (preload)
|
||||||
// A preloaded page must render synchronously on the first hydration pass.
|
// 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 })
|
return Object.assign (Page, { preload })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +80,6 @@ const MaterialListPage = lazyPage (() => import ('@/pages/materials/MaterialList
|
|||||||
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
|
const MaterialNewPage = lazyPage (() => import ('@/pages/materials/MaterialNewPage'))
|
||||||
const MaterialSyncSuppressionsPage = lazyPage (
|
const MaterialSyncSuppressionsPage = lazyPage (
|
||||||
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
|
() => import ('@/pages/materials/MaterialSyncSuppressionsPage'))
|
||||||
const CountsPage = lazyPage (() => import ('@/pages/CountsPage'))
|
|
||||||
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
|
const CountsResourcePage = lazyPage (() => import ('@/pages/CountsResourcePage'))
|
||||||
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
|
const MorePage = lazyPage (() => import ('@/pages/MorePage'))
|
||||||
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
|
const GekanatorUnavailablePage = lazyPage (() => import ('@/pages/GekanatorUnavailablePage'))
|
||||||
@@ -196,8 +198,8 @@ export const pageRoutes = (
|
|||||||
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
{ id: 'gekanator', path: 'gekanator', element: <GekanatorRoute user={user}/>,
|
||||||
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
|
handle: { locale: 'ja', sitemap: ['static'], preload: locale => locale === 'ja'
|
||||||
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
? GekanatorPage.preload () : GekanatorUnavailablePage.preload () } },
|
||||||
{ path: 'counts', element: <CountsPage/>,
|
{ path: 'counts', element: <CountsResourcePage resource="posts"/>,
|
||||||
handle: { preload: CountsPage.preload } },
|
handle: { preload: CountsResourcePage.preload } },
|
||||||
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
|
{ path: 'counts/posts', element: <CountsResourcePage resource="posts"/>,
|
||||||
handle: { preload: CountsResourcePage.preload } },
|
handle: { preload: CountsResourcePage.preload } },
|
||||||
{ path: 'counts/tags', element: <CountsResourcePage resource="tags"/>,
|
{ 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
|
const next = typeof action === 'function' ? action (authority.current) : action
|
||||||
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
|
if (next?.id !== authority.current?.id || next?.role !== authority.current?.role)
|
||||||
{
|
{
|
||||||
client.cancelQueries ()
|
const filters = {
|
||||||
client.removeQueries ({ predicate: query => query.queryKey[0] !== 'session' })
|
predicate: (query: { queryKey: readonly unknown[] }) =>
|
||||||
|
isAuthorityDependentQuery (query.queryKey) }
|
||||||
|
void client.cancelQueries (filters)
|
||||||
|
client.removeQueries (filters)
|
||||||
}
|
}
|
||||||
authority.current = next
|
authority.current = next
|
||||||
|
client.setQueryData (sessionKeys.current, next)
|
||||||
setUserState (next)
|
setUserState (next)
|
||||||
}, [client])
|
}, [client])
|
||||||
const [status, setStatus] = useState (200)
|
const [status, setStatus] = useState (200)
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export type CountsPivotHeader = { values: (string | number | null)[]
|
|||||||
kind: 'data' | 'subtotal' | 'grand_total' }
|
kind: 'data' | 'subtotal' | 'grand_total' }
|
||||||
export type CountsResult = {
|
export type CountsResult = {
|
||||||
populationCount?: number
|
populationCount?: number
|
||||||
excludedCount?: number
|
excludedCount?: number | null
|
||||||
|
excludedCountScope?: string
|
||||||
outputCount?: number | null
|
outputCount?: number | null
|
||||||
visibleOutputCount?: number
|
visibleOutputCount?: number
|
||||||
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
|
outputCountScope?: 'before_authorisation' | 'unavailable_private_history'
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export type CountsFieldType = 'string' | 'integer' | 'datetime' | 'enum'
|
|||||||
|
|
||||||
export const countsResources: CountsResource[] = [
|
export const countsResources: CountsResource[] = [
|
||||||
'posts', 'tags', 'nico_tags', 'tag_descriptions', 'tag_groups', 'materials']
|
'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 = {
|
export type CountsFieldMetadata = {
|
||||||
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
key: string; type: CountsFieldType; nullable: boolean; operators: string[]
|
||||||
labels?: string[]; multiple?: boolean }
|
labels?: string[]; multiple?: boolean }
|
||||||
|
|||||||
@@ -157,6 +157,15 @@ export const sessionKeys = {
|
|||||||
settings: (id: number) => ['session', id, 'settings'] as const,
|
settings: (id: number) => ['session', id, 'settings'] as const,
|
||||||
themeSlots: (id: number) => ['session', id, 'themeSlots'] 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 = {
|
export const theatresKeys = {
|
||||||
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
|
show: (id: string, locale: LocaleCode) => ['theatres', locale, id] as const,
|
||||||
programmes: (id: string, locale: LocaleCode) =>
|
programmes: (id: string, locale: LocaleCode) =>
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
"resources": {
|
"resources": {
|
||||||
"posts": "広場",
|
"posts": "広場",
|
||||||
"tags": "タグ",
|
"tags": "タグ",
|
||||||
"nico_tags": "ニコニコタグ",
|
"nico_tags": "外部タグ",
|
||||||
"tag_descriptions": "タグ説明",
|
"tag_descriptions": "タグ説明",
|
||||||
"tag_groups": "タググループ",
|
"tag_groups": "タググループ",
|
||||||
"materials": "素材"
|
"materials": "素材"
|
||||||
@@ -172,7 +172,7 @@
|
|||||||
"changedField": "変更項目",
|
"changedField": "変更項目",
|
||||||
"eventType": "異動区分",
|
"eventType": "異動区分",
|
||||||
"count": "件数",
|
"count": "件数",
|
||||||
"outputTotal": "綜件数:{{value}} 件",
|
"outputTotal": "件数:{{value}} 件",
|
||||||
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
|
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
|
||||||
"recordTotal": "閲覧可能件数:{{count}}",
|
"recordTotal": "閲覧可能件数:{{count}}",
|
||||||
"grain": "粒度",
|
"grain": "粒度",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { countsRequestParams, countsDefinitionParams, countsExportPayload, field
|
|||||||
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
|
||||||
import { countsExportLabels } from '@/lib/countsDisplay'
|
import { countsExportLabels } from '@/lib/countsDisplay'
|
||||||
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
import { CountsDefinitionContext, countsAxisFields, countsAxisLabel,
|
||||||
countsResources, countsFieldLabel,
|
visibleCountsResources, countsFieldLabel,
|
||||||
fetchCountsDefinition }
|
fetchCountsDefinition }
|
||||||
from '@/lib/countsDefinitions'
|
from '@/lib/countsDefinitions'
|
||||||
import { countsKeys } from '@/lib/queryKeys'
|
import { countsKeys } from '@/lib/queryKeys'
|
||||||
@@ -211,9 +211,6 @@ const CountsResourceContent = (
|
|||||||
const waiting = running || (!definitionError && (definition == null
|
const waiting = running || (!definitionError && (definition == null
|
||||||
|| isPending || isFetching || isPlaceholderData
|
|| isPending || isFetching || isPlaceholderData
|
||||||
|| (!params.has ('execution_at') && !isError)))
|
|| (!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>) => {
|
const update = (key: string, value: string, extra?: Record<string, string>) => {
|
||||||
@@ -287,7 +284,7 @@ const CountsResourceContent = (
|
|||||||
lang={message ('target').language}>
|
lang={message ('target').language}>
|
||||||
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
<h2 className="mb-2 font-semibold">{label ('target')}</h2>
|
||||||
<ul className="space-y-1">
|
<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
|
<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
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500
|
||||||
dark:focus-visible:ring-blue-400"
|
dark:focus-visible:ring-blue-400"
|
||||||
@@ -352,16 +349,15 @@ const CountsResourceContent = (
|
|||||||
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
{(definitionError || isError || runError) && <p role="alert">{label ('common:fetchError')}</p>}
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
aria-busy={waiting}>
|
aria-busy={waiting}>
|
||||||
<div inert={waiting} aria-hidden={waiting || undefined}
|
<div inert={waiting} className="flex min-h-0 flex-1 flex-col">
|
||||||
className={cn ('flex min-h-0 flex-1 flex-col',
|
|
||||||
waiting && (compatible ? 'pointer-events-none opacity-40' : 'invisible'))}>
|
|
||||||
{definition != null && data != null && <>
|
{definition != null && data != null && <>
|
||||||
{data.excludedCount != null && (
|
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
||||||
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
|
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||||
<span>{label ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
{data.excludedCountScope === 'current_query'
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
&& data.excludedCount != null && data.excludedCount > 0 && (
|
||||||
{label ('excludedRecords', { count: data.excludedCount })}</span>
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</p>)}
|
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
||||||
|
</p>
|
||||||
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
|
{resultMode === 'summary' ? <CountsSummaryResults resource={resource}
|
||||||
data={data} params={resultParams}
|
data={data} params={resultParams}
|
||||||
pending={waiting} update={update}/> : (
|
pending={waiting} update={update}/> : (
|
||||||
@@ -372,7 +368,7 @@ const CountsResourceContent = (
|
|||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
{waiting && <div role="status" aria-live="polite"
|
{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
|
<span className="rounded border border-slate-300 bg-white px-2 py-1
|
||||||
text-sm text-slate-900 dark:border-slate-600
|
text-sm text-slate-900 dark:border-slate-600
|
||||||
dark:bg-slate-950 dark:text-slate-100">
|
dark:bg-slate-950 dark:text-slate-100">
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする