上映会改修 (#302) #357

マージ済み
みてるぞ が 13 個のコミットを feature/302 から main へマージ 2026-06-07 02:51:26 +09:00
8個のファイルの変更482行の追加283行の削除
コミット 364d154b6a の変更だけを表示してゐます - すべてのコミットを表示
+52 -19
ファイルの表示
@@ -12,16 +12,21 @@ BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
## Stack
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`, `factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`, `aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Backend dependencies include `mysql2`, `sqlite3`, `rspec-rails`,
`factory_bot_rails`, `rack-cors`, `jwt`, `discard`, `gollum`, `whenever`,
`aws-sdk-s3`, `brakeman`, and `rubocop-rails-omakase`.
- Frontend: React `^19.1.0`, TypeScript `~5.8.3`, Vite `^6.3.5`.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS, Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and Zustand.
- Frontend data/UI dependencies include Axios, TanStack Query, Tailwind CSS,
Framer Motion, Radix UI components, lucide-react, MDX/Markdown tooling, and
Zustand.
## Main directories
- `backend/app/controllers`: Rails API controllers.
- `backend/app/models`: Active Record models.
- `backend/app/representations`: API response representation classes.
- `backend/app/services`: domain services such as version recording, wiki commit, YouTube sync, and similarity calculation.
- `backend/app/services`: domain services such as version recording,
wiki commit, YouTube sync, and similarity calculation.
- `backend/config/routes.rb`: API routes.
- `backend/db/migrate`: migrations.
- `backend/db/schema.rb`: current schema snapshot.
@@ -89,7 +94,8 @@ npm run test:run
npm run preview
```
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs `node scripts/generate-sitemap.js`.
`npm run build` runs `tsc -b && vite build`, then `postbuild` runs
`node scripts/generate-sitemap.js`.
`npm run test` runs Vitest in watch mode. Use `npm run test:run` for a non-watch frontend test run.
@@ -102,36 +108,59 @@ npm run preview
- Ruby: never put a space before method-call parentheses.
- Ruby: do not use `%w` or `%i`.
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
- Never write Ruby, TypeScript, or TSX lines longer than 99 characters.
- Aim to keep Ruby, TypeScript, and TSX lines within 79 characters where practical.
- TypeScript and TSX use 4-space logical indentation.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab.
- Tabs are only for leading indentation, never for spaces after non-space text.
- Do not add production dependencies without explicit approval.
## Backend rules
- Inspect existing routes, controllers, models, services, and specs before editing backend behavior.
- Inspect existing routes, controllers, models, services, and specs before
editing backend behavior.
- For API behavior changes, add or update request specs under `backend/spec/requests`.
- Prefer RSpec for new backend tests; existing minitest files under `backend/test` do not make minitest the default for new coverage.
- Prefer RSpec for new backend tests; existing minitest files under
`backend/test` do not make minitest the default for new coverage.
- Do not weaken authentication, BAN user checks, or IP BAN checks.
- Preserve the `X-Transfer-Code` user identification flow unless the task explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior.
- Preserve the `X-Transfer-Code` user identification flow unless the task
explicitly changes authentication.
- Be careful with version tables, `version_no`, optimistic concurrency,
wiki revisions, and restore/diff behavior.
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
## Frontend rules
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`; avoid ad hoc query key arrays.
- Add or reuse TanStack Query keys through `frontend/src/lib/queryKeys.ts`;
avoid ad hoc query key arrays.
- Encode URL path-segment values with `encodeURIComponent`.
- React hooks must be called unconditionally.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code under `frontend/src/components` unless existing patterns point elsewhere.
- Keep page-level code under `frontend/src/pages` and shared UI/feature code
under `frontend/src/components` unless existing patterns point elsewhere.
- Match existing Tailwind, component, and import alias conventions.
### Frontend TSX style
- Preserve the local TSX formatting style. Do not normalize TSX to common Prettier-style React formatting unless explicitly asked.
- Preserve the local TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent nested tag attributes with one tab relative to the tag line. With the project tab width, this visually appears as 4 spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag spans multiple lines. Do not put `/>` or `>` on its own line unless the existing surrounding code does so.
- Keep JSX closing parentheses in the existing compact style, for example `</div>)` rather than moving `)` onto a separate line.
- Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward.
- In TSX, indent with 4-space logical indentation.
- A leading tab is exactly equivalent to 8 leading spaces.
- Keep a tag's closing marker on the same line as the final prop when the tag
spans multiple lines.
- Do not put `/>` or `>` on its own line unless the existing surrounding code
does so.
- Keep JSX closing parentheses in the existing compact style, for example
`</div>)` rather than moving `)` onto a separate line.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
Preferred:
@@ -164,10 +193,14 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
- First inspect existing patterns; do not invent new architecture when a local convention exists.
- Keep changes scoped to the requested issue.
- Do not scan or summarize dependency/generated/runtime directories such as `node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
- Do not scan or summarize dependency/generated/runtime directories such as
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
behavior, inspect the related request specs and service objects.
- If frontend code changes, run the existing frontend verification commands
that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
- If backend code changes, run the relevant RSpec command; for broad backend
changes, run `bundle exec rspec`.
- If a verification command cannot be run or fails, report the exact command and failure.
## Completion criteria
+76 -25
ファイルの表示
@@ -4,7 +4,9 @@
These rules apply to work under `backend/`.
This is a Rails API app using Active Record, RSpec, request specs, service objects, representation classes, and version tables for post/tag/wiki history.
This is a Rails API app using Active Record, RSpec, request specs,
service objects, representation classes, and version tables for post/tag/wiki
history.
## Commands
@@ -50,14 +52,16 @@ If a command cannot be run or fails, report the exact command and failure.
- `app/controllers`: API controllers.
- `app/models`: Active Record models and concerns.
- `app/representations`: JSON response shaping.
- `app/services`: domain services such as version recorders, wiki commit, YouTube sync, and similarity calculation.
- `app/services`: domain services such as version recorders, wiki commit,
YouTube sync, and similarity calculation.
- `config/routes.rb`: public API routes.
- `db/migrate`: migrations.
- `db/schema.rb`: schema snapshot.
- `lib/tasks`: custom Rake tasks.
- `spec`: RSpec tests.
Before changing behavior, inspect the matching route, controller, model, service, representation, and spec.
Before changing behavior, inspect the matching route, controller, model,
service, representation, and spec.
## Ruby style
@@ -65,17 +69,29 @@ Before changing behavior, inspect the matching route, controller, model, service
- Use single quotes unless interpolation or escaping makes double quotes better.
- Do not put a space before Ruby method-call parentheses.
- Do not use `%w` or `%i` in new Ruby code.
- Never write a Ruby line longer than 99 characters.
- Aim to keep Ruby lines within 79 characters where practical.
- For small Ruby method definitions that take keyword arguments, match the
local no-parentheses style when nearby code uses it.
- For multi-line Ruby hashes and keyword constructors, prefer a readable
vertical shape with the opening brace on its own line.
- Put one logical field per line when the expression would otherwise
become dense.
- Keep comments short and useful; avoid narrating obvious code.
- Do not add production dependencies without approval.
## Authentication and authorization
- Authentication is handled through the `X-Transfer-Code` header in `ApplicationController#authenticate_user`.
- Authentication is handled through the `X-Transfer-Code` header in
`ApplicationController#authenticate_user`.
- `current_user` is set by looking up `User.inheritance_code`.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers.
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
explicitly changes authentication.
- Unauthenticated write actions should return `:unauthorized` consistently
with existing controllers.
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
- Use `current_user.gte_member?` for member-or-admin write permissions where existing controllers do so.
- Use `current_user.gte_member?` for member-or-admin write permissions where
existing controllers do so.
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
- Do not replace role checks with looser presence checks.
@@ -88,7 +104,8 @@ Before changing behavior, inspect the matching route, controller, model, service
- User and IP bans use `banned_at`, not a boolean `banned` column.
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
- Do not weaken BAN or IP BAN behavior.
- If changing request authentication or controller before actions, add or update request specs covering banned users and banned IP addresses.
- If changing request authentication or controller before actions, add or
update request specs covering banned users and banned IP addresses.
## RSpec
@@ -99,49 +116,83 @@ Before changing behavior, inspect the matching route, controller, model, service
- Put Rake task coverage under `spec/tasks`.
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
- Request specs include `AuthHelper` and `JsonHelper`.
- `AuthHelper#sign_in_as(user)` stubs `ApplicationController#current_user`; use it when matching existing request spec style.
- Add or update request specs for API behavior changes, especially status codes, permissions, response shape, and version conflict behavior.
- `AuthHelper#sign_in_as(user)` stubs
`ApplicationController#current_user`; use it when matching existing
request spec style.
- Add or update request specs for API behavior changes, especially status
codes, permissions, response shape, and version conflict behavior.
## Migrations
- Keep migrations and `db/schema.rb` consistent.
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
- For data backfills inside migrations, follow the existing pattern of defining migration-local `ActiveRecord::Base` classes with `self.table_name`.
- For data backfills inside migrations, follow the existing pattern of
defining migration-local `ActiveRecord::Base` classes with
`self.table_name`.
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
- Do not edit old migrations just to change current behavior unless explicitly requested; add a new migration.
- Do not edit old migrations just to change current behavior unless
explicitly requested; add a new migration.
## Version tables
- Versioned records include posts, tags, nico tags, and wiki pages.
- Current records have `version_no`; version tables have positive `version_no` with unique indexes scoped to the parent record.
- Current records have `version_no`; version tables have positive
`version_no` with unique indexes scoped to the parent record.
- Version event types are `create`, `update`, `discard`, and `restore`.
- Version rows are readonly through the `VersionRecord` concern.
- Use the existing recorder services instead of manually inserting version rows in application code:
- Use the existing recorder services instead of manually inserting version
rows in application code:
- `PostVersionRecorder`
- `TagVersionRecorder`
- `NicoTagVersionRecorder`
- `WikiVersionRecorder`
- `TagVersioning`
- `VersionRecorder` locks the current record, validates sequence consistency, skips unchanged update snapshots, creates the next version row, and updates the record `version_no`.
- `VersionRecorder` locks the current record, validates sequence consistency,
skips unchanged update snapshots, creates the next version row, and updates
the record `version_no`.
- Do not update versioned records without considering whether a version snapshot must be created.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and `merge` semantics and cover conflicts in request specs.
- For optimistic concurrency paths, preserve `base_version_no`, `force`, and
`merge` semantics and cover conflicts in request specs.
## Domain cautions
- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect the controller before changing them.
- Posts have tag snapshots, parent post implications, original-created ranges,
viewed state, and version conflict behavior.
- Tags have canonical names, aliases through `TagName`, categories, parent
implications, discard behavior, and version snapshots.
- Nico tags have separate relation/version behavior; do not treat them like
normal editable tags without checking existing code.
- Wiki pages involve page content, revisions/history, version rows,
title/tag-name behavior, and diff/restore paths.
- Materials, theatres, and comments have user and permission checks; inspect
the controller before changing them.
## API responses
- Use representation classes under `app/representations` when existing endpoints do.
- Keep response keys consistent with existing JSON contracts; frontend code expects camelCase conversion client-side, while Rails params and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions: `:unauthorized` for no user, `:forbidden` for insufficient role or banned user, `:not_found` for missing records, and `:unprocessable_entity` for validation failures.
- Keep response keys consistent with existing JSON contracts.
- Frontend code expects camelCase conversion client-side, while Rails params
and JSON keys are generally snake_case.
- Preserve existing HTTP status conventions:
`:unauthorized` for no user, `:forbidden` for insufficient role or banned
user, `:not_found` for missing records, and `:unprocessable_entity` for
validation failures.
- For diagnostic or internal helper JSON, prefer a deliberately light response
shape over full representation classes when callers only need identifiers,
labels, URLs, or weights.
## Active Record performance
- When a controller action serializes nested associations, preload the
associations it will touch instead of allowing N+1 queries.
- When an association may already be preloaded, prefer loaded-association
checks that reuse the preloaded data without losing the efficient database
path.
## Files to avoid in routine work
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding migration when schema changes are made.
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
directories unless explicitly needed.
- Do not modify generated schema or migration output without the corresponding
migration when schema changes are made.
+3
ファイルの表示
@@ -9,6 +9,9 @@ class TheatreProgrammesController < ApplicationController
programmes = TheatreProgramme
.where(theatre_id: params[:theatre_id])
.where('position > ?', position_gt)
.includes(post: [:uploaded_user, :parents, :children,
{ thumbnail_attachment: :blob },
{ tags: [:deerjikists, :materials, { tag_name: :wiki_page }] }])
.order(position: :desc).limit(100)
.limit(limit)
+1 -1
ファイルの表示
@@ -81,7 +81,7 @@ class Tag < ApplicationRecord
def material_id = materials.first&.id
def has_deerjikists = deerjikists.exists?
def has_deerjikists = deerjikists.loaded? ? deerjikists.any? : deerjikists.exists?
def self.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
+50 -18
ファイルの表示
@@ -1,7 +1,7 @@
class TheatrePostSelector
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
def initialize(theatre:)
def initialize theatre:
@theatre = theatre
end
@@ -20,13 +20,15 @@ class TheatrePostSelector
candidates.last.post
end
def weight_json(limit: 20)
def weight_json limit: 20
candidates = weighted_candidates
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
{ tag_penalties: tag_penalty_json,
{
tag_penalties: tag_penalty_json,
lightest_posts: post_weight_json(sorted.first(limit)),
heaviest_posts: post_weight_json(sorted.reverse.first(limit)) }
heaviest_posts: post_weight_json(sorted.reverse.first(limit))
}
end
private
@@ -41,7 +43,13 @@ class TheatrePostSelector
posts.map do |post|
post_tags = post.tags.to_a
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
Candidate.new(post:, penalty:, tags: post_tags, weight: 1.0 / (1.0 + penalty))
Candidate.new(
post:,
penalty:,
tags: post_tags,
weight: 1.0 / (1.0 + penalty)
)
end
end
end
@@ -58,35 +66,59 @@ class TheatrePostSelector
def tag_penalties
@tag_penalties ||=
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
if active_user_ids.empty?
{}
else
TheatreSkipEventVoter
.joins(theatre_skip_event: :event_tags)
.where(user_id: active_user_ids)
.group('theatre_skip_event_tags.tag_id')
.count
end
end
end
def tag_penalty_json
return [] if tag_penalties.empty?
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
tag_penalties.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{ tag: TagRepr.inline(tag), penalty: }
}.compact.sort_by { |row| [-row[:penalty], row[:tag]['name'].to_s] }
tag_penalties
.map { |tag_id, penalty|
tag = tags[tag_id]
next unless tag
{
tag: light_tag_json(tag),
penalty:
}
}
.compact
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
end
def post_weight_json(candidates)
def post_weight_json candidates
candidates.map { |candidate|
{ post: PostRepr.base(candidate.post),
{
post: light_post_json(candidate.post),
weight: candidate.weight,
penalty: candidate.penalty,
tags: candidate.tags.map { |tag| TagRepr.inline(tag) } }
tags: candidate.tags.map { |tag| light_tag_json(tag) }
}
}
end
def light_post_json post
{
id: post.id,
title: post.title,
url: post.url
}
end
def light_tag_json tag
{
id: tag.id,
name: tag.name
}
end
end
+64 -16
ファイルの表示
@@ -4,7 +4,8 @@
These rules apply to work under `frontend/`.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
Framer Motion, Radix UI-style components, MDX, and Zustand.
## Commands
@@ -17,9 +18,11 @@ npm run lint
npm run preview
```
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs
`node scripts/generate-sitemap.js`.
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
There is currently no `test` script in `package.json`. Do not run or report
`npm test` unless a test script is added.
After frontend changes, run:
@@ -32,18 +35,33 @@ If either command cannot be run or fails, report the exact command and failure.
## TypeScript
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
- Keep types explicit at module boundaries, API helpers, and exported utilities.
- Use `import type` for type-only imports.
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
- Preserve the repository's existing spacing style in TypeScript, including
GNU-style spacing before call parentheses where it is already used.
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
- Never write a TypeScript or TSX line longer than 99 characters.
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
- Use 4-space logical indentation in TypeScript and TSX.
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
to reduce bytes.
- Treat one leading tab as exactly equivalent to 8 leading spaces.
- Use tabs only for leading indentation. Never replace spaces that occur after
a non-space character on the same line.
## React
- Use function components.
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
- Existing page components commonly export an anonymous function satisfying
`FC`; match nearby file style when editing.
- React hooks must be called unconditionally and at the top level of components or custom hooks.
- Gate editing and other privileged controls with shared permission helpers
such as `canEditContent`, instead of showing controls and relying only on a
later API failure.
- Keep page-level components under `src/pages`.
- Keep shared and feature components under `src/components`.
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
@@ -52,17 +70,23 @@ If either command cannot be run or fails, report the exact command and failure.
## TanStack Query
- Use `@tanstack/react-query` for server state.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
instead of using ad hoc arrays in components.
- Fetch functions should live in domain helpers under `src/lib`, such as
`posts.ts`, `tags.ts`, or `wiki.ts`.
- Use `useQueryClient().invalidateQueries` with the shared root keys when
mutations affect cached lists or detail views.
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
additional clients in feature code.
## API calls
- Use `src/lib/api.ts` for HTTP calls.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
non-blob responses to camelCase.
- Send Rails snake_case params and request body keys where the backend expects them.
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
- Do not bypass the API wrapper unless there is a specific reason, such as a
third-party request outside the Rails API.
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
## Imports and aliases
@@ -76,17 +100,41 @@ If either command cannot be run or fails, report the exact command and failure.
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
- Reuse components from `src/components/common`, `src/components/layout`, and
`src/components/ui` before adding new primitives.
- Keep Tailwind classes consistent with nearby components.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
heavy borders, or nested panel decoration unless the surrounding screen
already uses them.
- Keep operational screens dense and direct; trim explanatory copy and use
short Japanese labels that fit the control.
- Preserve existing Japanese tone and orthography in nearby UI text, including
old-kana wording where the file already uses it.
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting
- Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them.
- For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression.
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
single physical line.
- Always add braces around `if`, `else`, or `for` bodies when the body spans
two or more physical lines, even if it is one statement.
- Avoid reformatting unrelated JSX.
## Lint and build constraints
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
and `eslint-plugin-react-refresh`.
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
- Build failures from unused locals or unused parameters are TypeScript
errors, not lint-only issues.
## Files to avoid in routine work
+1
ファイルの表示
@@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types'
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
export const canEditContent = (
user: Pick<User, 'role'> | null | undefined,
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
+235 -204
ファイルの表示
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { fetchPost } from '@/lib/posts'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
@@ -42,11 +43,10 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
postStartedAt: null,
postElapsedMs: null,
watchingUsers: [],
skipVote: {
votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false } }
skipVote: { votesCount: 0,
requiredCount: 1,
watchingUsersCount: 0,
voted: false } }
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
@@ -56,12 +56,12 @@ const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
tagsBottom: '2 列(コメント欄)',
commentsBottom: '2 列(タグ欄)' }
tagsBottom: '2 列 A 型',
commentsBottom: '2 列 B 型' }
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
vertical: 'タグ縦',
horizontal: 'タグ横' }
vertical: '縦並び',
horizontal: '横並び' }
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
@@ -88,13 +88,13 @@ const commentBox = (
</div>),
(
<div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
{programme ? (
{programme && (
<>
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
</>) : 'この時の動画:履歴外'}
&thinsp;
</>)}
</div>)]
@@ -124,13 +124,15 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
const grouped = tagsByCategory (tags)
if (flow === 'horizontal')
return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul>)
{
return (
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
</li>))}
</ul>)
}
return (
<div className="space-y-3">
@@ -144,10 +146,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
{CATEGORY_NAMES[cat]}
</div>
<ul
className={cn (
'space-y-1',
compact && 'text-sm')}>
<ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.map (tag => (
<li key={tag.id} className="text-left leading-tight">
<TagLink tag={tag} withCount={false}/>
@@ -188,16 +187,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
return (['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode)
? stored as TheatreLayoutMode
: 'threeColumns'
return (
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
.includes (stored as TheatreLayoutMode))
? (stored as TheatreLayoutMode)
: 'threeColumns')
})
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
return (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? stored as TagFlow
: 'vertical'
return (
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
? (stored as TagFlow)
: 'vertical')
})
const { fieldErrors, clearValidationErrors, applyValidationError } =
useValidationErrors<TheatreCommentField> ()
@@ -217,14 +218,15 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
setTheatreInfo (nextInfo)
}, [])
const currentPostElapsedMs = useCallback ((info: TheatreInfo = theatreInfoRef.current): number => {
if (info.postElapsedMs == null)
return 0
const currentPostElapsedMs = useCallback (
(info: TheatreInfo = theatreInfoRef.current): number => {
if (info.postElapsedMs == null)
return 0
return Math.max (
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
0)
}, [])
return Math.max (
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
0)
}, [])
const refreshProgrammes = useCallback (async () => {
if (!(id))
@@ -352,11 +354,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (ended)
{
if (!(cancelled))
setTheatreInfo (prev => ({
...prev,
postId: null,
postStartedAt: null,
postElapsedMs: null }))
{
setTheatreInfo (prev => ({
...prev,
postId: null,
postStartedAt: null,
postElapsedMs: null }))
}
return
}
@@ -561,7 +565,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
return <ErrorScreen status={status}/>
const tagPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold"></h2>
{layoutMode === 'tagsBottom' && (
@@ -583,7 +587,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</section>)
const commentsPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold"></h2>
<form onSubmit={handleCommentSubmit}>
<input
@@ -598,18 +602,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<div
ref={commentsRef}
className="mt-3 max-h-[48vh] overflow-y-auto rounded border border-zinc-200 dark:border-zinc-800">
className="mt-3 max-h-[48vh] overflow-y-auto rounded border border-zinc-200
dark:border-zinc-800">
{comments.map (comment => {
const commentProgramme = programmeForComment (comment)
return (
<div
key={comment.no}
className="group relative border-t border-zinc-100 p-2 first:border-t-0 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
className="group relative border-t border-zinc-100 p-2 first:border-t-0
hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
{(user && comment.user?.id === user.id && !(comment.deleted)) && (
<button
type="button"
className="absolute left-1 top-1 hidden rounded px-1 text-red-600 hover:bg-red-100 group-hover:inline-block dark:text-red-300 dark:hover:bg-red-950"
className="absolute left-1 top-1 hidden rounded px-1 text-red-600
hover:bg-red-100 group-hover:inline-block dark:text-red-300
dark:hover:bg-red-950"
aria-label="コメントを削除"
onClick={async e => {
e.stopPropagation ()
@@ -617,7 +624,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?',
description: (
<div className="my-3 w-64 rounded border border-black p-2 dark:border-white">
<div className="my-3 w-120 rounded border border-black p-2
dark:border-white">
{commentBox (comment, commentProgramme)}
</div>),
confirmText: '削除',
@@ -635,7 +643,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</section>)
const participantsPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold"></h2>
<div className="space-y-1">
{theatreInfo.watchingUsers.map (watchingUser => (
@@ -647,43 +655,56 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</section>)
const historyPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold"></h2>
<div className="max-h-72 overflow-y-auto">
{programmes.length === 0 ? (
<div className="text-sm text-zinc-500"></div>) : programmes.map (programme => (
<div key={`${ programme.theatreId }-${ programme.position }`} className="border-t border-zinc-100 py-2 text-sm first:border-t-0 dark:border-zinc-800">
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
<div className="text-xs text-zinc-500">
#{programme.position} / {dateString (programme.createdAt)}
</div>
</div>))}
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
overflow-y-auto">
{programmes.length === 0
? <div className="text-sm text-zinc-500"></div>
: (
programmes.map (programme => (
<div
key={`${ programme.theatreId }-${ programme.position }`}
className="border-zinc-100 p-2 text-sm first:border-t-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ programme.post.id }`}
className="font-bold hover:underline">
{programme.post.title || programme.post.url}
</PrefetchLink>
<div className="text-xs text-zinc-500">
{dateString (programme.createdAt)}
</div>
</div>)))}
</div>
</section>)
const weightsPanel = (
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="font-bold"></h2>
<h2 className="font-bold"></h2>
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
</Button>
</div>
<div className="grid gap-4 xl:grid-cols-3">
<div className="mx-4 grid gap-16 xl:grid-cols-3">
<div>
<h3 className="mb-2 text-sm font-bold"></h3>
<h3 className="mb-2 text-sm font-bold"></h3>
<div className="space-y-1 text-sm">
{weights.tagPenalties.length === 0 ? (
<div className="text-zinc-500"></div>) : weights.tagPenalties.slice (0, 12).map (row => (
<div key={row.tag.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2 text-left">
<div className="min-w-0 text-left">
<TagLink tag={row.tag} withCount={false}/>
</div>
<span className="font-mono">{row.penalty}</span>
</div>))}
{weights.tagPenalties.length === 0
? <div className="text-zinc-500"></div>
: (
weights.tagPenalties.slice (0, 12).map (row => (
<div
key={row.tag.id}
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2
text-left">
<div className="min-w-0 text-left">
<TagLink tag={row.tag} withCount={false}/>
</div>
<span className="font-mono">{row.penalty}</span>
</div>)))}
</div>
</div>
@@ -710,14 +731,19 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</Helmet>
<div className={cn (
'grid min-h-full gap-4 overflow-visible p-3 md:h-full md:overflow-hidden',
layoutMode === 'threeColumns' && 'md:grid-cols-[16rem_minmax(0,1fr)_22rem] xl:grid-cols-[18rem_minmax(0,1fr)_24rem]',
layoutMode === 'tagsBottom' && 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]',
layoutMode === 'commentsBottom' && 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]')}>
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
(layoutMode === 'threeColumns'
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
(layoutMode === 'tagsBottom'
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
(layoutMode === 'commentsBottom'
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
{layoutMode !== 'tagsBottom' && (
<motion.aside
layout="position"
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto md:[direction:rtl]">
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
md:[direction:rtl]">
<div className="md:[direction:ltr]">
{tagPanel}
</div>
@@ -725,145 +751,143 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<motion.main
layout="position"
className={cn (
'order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className={cn (layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border border-zinc-300 bg-white dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div>
<h1 className="text-lg font-bold">{theatreTitle}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{theatreInfo.watchingUsers.length}
</p>
</div>
<div className="flex flex-wrap gap-2">
<div className="hidden flex-wrap gap-2 md:flex">
{(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => (
<Button
key={mode}
type="button"
size="sm"
variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]}
</Button>))}
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
<section className="overflow-hidden rounded border-zinc-300
dark:border-zinc-800">
<div className="flex flex-wrap items-center justify-between gap-3
border-zinc-200 px-4 py-3 dark:border-zinc-800">
<div>
<h1 className="text-lg font-bold">{theatreTitle}</h1>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
{theatreInfo.watchingUsers.length}
</p>
</div>
<Button
type="button"
size="sm"
variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)}
onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button>
<div className="flex flex-wrap gap-2">
<div className="hidden flex-wrap gap-2 md:flex">
{(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => (
<Button
key={mode}
type="button"
size="sm"
variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]}
</Button>))}
</div>
</div>
</div>
<Button
type="button"
size="sm"
variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)}
onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button>
<div className="flex justify-center bg-black">
{post ? (
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={info => {
embedRef.current?.play ()
setVideoLength (info.lengthInSeconds * 1_000)
}}
onMetadataChange={syncPlayback}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
</div>)}
</div>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
</div>
</div>
<div className="flex justify-center bg-black mx-4">
{post ? (
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold hover:underline">
{post.title || post.url}
</PrefetchLink>) : (
<span className="text-zinc-500"></span>)}
<PostEmbed
key={post.id}
ref={embedRef}
post={post}
onLoadComplete={info => {
embedRef.current?.play ()
setVideoLength (info.lengthInSeconds * 1_000)
}}
onMetadataChange={syncPlayback}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
</div>)}
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (post)}>
稿
</Button>
</div>
</section>
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
</div>
{post ? (
<PrefetchLink
to={`/posts/${ post.id }`}
className="font-bold hover:underline">
{post.title || post.url}
</PrefetchLink>) : (
<span className="text-zinc-500"></span>)}
</div>
{editingPost && (
<section className="rounded border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="font-bold">稿</h2>
{(post && canEditContent (user)) && (
<Button
type="button"
size="sm"
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
{editingPost ? '閉じる' : '編輯'}
</Button>)}
</div>
</section>
{editingPost && (
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
dark:border-amber-800 dark:bg-amber-950/30">
<div className="mb-3">
<h2 className="font-bold">稿</h2>
<p className="text-sm text-amber-900 dark:text-amber-100">
<PrefetchLink
to={`/posts/${ editingPost.id }`}
className="mx-1 font-bold underline">
{editingPost.title || editingPost.url}
</PrefetchLink>
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => setEditingPost (null)}>
</Button>
</div>
<PostEditForm
post={editingPost}
onSave={newPost => {
setEditingPost (newPost)
if (post?.id === newPost.id)
setPost (newPost)
void refreshWeights ()
}}/>
</section>)}
<PostEditForm
post={editingPost}
onSave={newPost => {
setEditingPost (newPost)
if (post?.id === newPost.id)
setPost (newPost)
void refreshWeights ()
}}/>
</section>)}
<div className="md:hidden">
{commentsPanel}
</div>
<div className="md:hidden">
{commentsPanel}
</div>
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{commentsPanel}
</div>)}
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{commentsPanel}
</div>)}
<div className="md:hidden">
{tagPanel}
</div>
<div className="md:hidden">
{tagPanel}
</div>
{layoutMode === 'tagsBottom' && (
<div className="hidden md:block">
{tagPanel}
</div>)}
{layoutMode === 'tagsBottom' && (
<div className="hidden md:block">
{tagPanel}
</div>)}
{historyPanel}
{weightsPanel}
{historyPanel}
{weightsPanel}
<div className="md:hidden">
{participantsPanel}
</div>
<div className="md:hidden">
{participantsPanel}
</div>
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{participantsPanel}
</div>)}
{layoutMode === 'commentsBottom' && (
<div className="hidden md:block">
{participantsPanel}
</div>)}
</div>
</motion.main>
@@ -881,17 +905,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
<div className="space-y-2 text-sm">
{rows.length === 0 ? (
<div className="text-zinc-500"></div>) : rows.slice (0, 8).map (row => (
<div key={row.post.id} className="border-t border-zinc-100 pt-2 first:border-t-0 first:pt-0 dark:border-zinc-800">
<PrefetchLink to={`/posts/${ row.post.id }`} className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url}
</PrefetchLink>
<div className="flex justify-between gap-2 text-xs text-zinc-500">
<span>penalty {row.penalty}</span>
<span>weight {row.weight.toFixed (3)}</span>
</div>
</div>))}
{rows.length === 0
? <div className="text-zinc-500"></div>
: (
rows.slice (0, 8).map (row => (
<div
key={row.post.id}
className="border-zinc-100 pt-2 first:border-t-0 first:pt-0
dark:border-zinc-800">
<PrefetchLink
to={`/posts/${ row.post.id }`}
className="line-clamp-1 font-bold hover:underline">
{row.post.title || row.post.url}
</PrefetchLink>
<div className="flex justify-between gap-2 text-xs text-zinc-500">
<span>penalty {row.penalty}</span>
<span>weight {row.weight.toFixed (3)}</span>
</div>
</div>)))}
</div>)