diff --git a/AGENTS.md b/AGENTS.md index 7ab89a3..21f6ccb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `)` 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 + `)` 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 diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 33d1b1f..773b8ee 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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. diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb index b8b9dd5..4f479f7 100644 --- a/backend/app/controllers/theatre_programmes_controller.rb +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -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) diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index cad85e9..5048e45 100644 --- a/backend/app/models/tag.rb +++ b/backend/app/models/tag.rb @@ -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) diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb index bbff14b..2da5092 100644 --- a/backend/app/services/theatre_post_selector.rb +++ b/backend/app/services/theatre_post_selector.rb @@ -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 diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d24aaa2..9150c92 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 `)` 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 diff --git a/frontend/src/lib/users.ts b/frontend/src/lib/users.ts index 212e66e..9f31329 100644 --- a/frontend/src/lib/users.ts +++ b/frontend/src/lib/users.ts @@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types' const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member'] + export const canEditContent = ( user: Pick | null | undefined, ): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 6a7d103..4369b5b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -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 = { threeColumns: '3 列', - tagsBottom: '2 列(コメント欄)', - commentsBottom: '2 列(タグ欄)' } + tagsBottom: '2 列 A 型', + commentsBottom: '2 列 B 型' } const TAG_FLOW_LABELS: Record = { - vertical: 'タグ縦', - horizontal: 'タグ横' } + vertical: '縦並び', + horizontal: '横並び' } const userName = (user: Pick | null | undefined): string => @@ -88,13 +88,13 @@ const commentBox = ( ), (
- {programme ? ( + {programme && ( <> - この時の動画: {programme.post.title || programme.post.url} - ) : 'この時の動画:履歴外'} +  へのコメント + )}
)] @@ -124,13 +124,15 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( const grouped = tagsByCategory (tags) if (flow === 'horizontal') - return ( -
    - {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => ( -
  • - -
  • ))} -
) + { + return ( +
    + {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => ( +
  • + +
  • ))} +
) + } return (
@@ -144,10 +146,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
{CATEGORY_NAMES[cat]}
-
    +
      {rows.map (tag => (
    • @@ -188,16 +187,18 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const [weights, setWeights] = useState (INITIAL_WEIGHTS) const [layoutMode, setLayoutMode] = useState (() => { 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 (() => { 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 () @@ -217,14 +218,15 @@ const TheatreDetailPage: FC = ({ 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 = ({ 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 = ({ user }: Props) => { return const tagPanel = ( -
      +

      タグ

      {layoutMode === 'tagsBottom' && ( @@ -583,7 +587,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
      ) const commentsPanel = ( -
      +

      コメント

      = ({ user }: Props) => {
      + 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 (
      + 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)) && (
      ) const participantsPanel = ( -
      +

      参加者

      {theatreInfo.watchingUsers.map (watchingUser => ( @@ -647,43 +655,56 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
      ) const historyPanel = ( -
      +

      再生履歴

      -
      - {programmes.length === 0 ? ( -
      まだ履歴はありません。
      ) : programmes.map (programme => ( -
      - - {programme.post.title || programme.post.url} - -
      - #{programme.position} / {dateString (programme.createdAt)} -
      -
      ))} +
      + {programmes.length === 0 + ?
      まだ履歴はありません。
      + : ( + programmes.map (programme => ( +
      + + {programme.post.title || programme.post.url} + +
      + {dateString (programme.createdAt)} +
      +
      )))}
      ) const weightsPanel = ( -
      +
      -

      今の抽選重み

      +

      抽選重み

      -
      +
      -

      下がってゐるタグ

      +

      出にくいタグ

      - {weights.tagPenalties.length === 0 ? ( -
      まだ減点はありません。
      ) : weights.tagPenalties.slice (0, 12).map (row => ( -
      -
      - -
      - {row.penalty} -
      ))} + {weights.tagPenalties.length === 0 + ?
      まだ減点はありません。
      + : ( + weights.tagPenalties.slice (0, 12).map (row => ( +
      +
      + +
      + {row.penalty} +
      )))}
      @@ -710,14 +731,19 @@ const TheatreDetailPage: FC = ({ user }: Props) => {
      + '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' && ( + className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto + md:[direction:rtl]">
      {tagPanel}
      @@ -725,145 +751,143 @@ const TheatreDetailPage: FC = ({ user }: Props) => { -
      -
      -
      -
      -

      {theatreTitle}

      -

      - 同接 {theatreInfo.watchingUsers.length} 人 -

      -
      - -
      -
      - {(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => ( - ))} + className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto', + layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}> +
      +
      +
      +
      +

      {theatreTitle}

      +

      + 同接 {theatreInfo.watchingUsers.length} 人 +

      - +
      +
      + {(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => ( + ))} +
      -
      -
      + -
      - {post ? ( - { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} - onMetadataChange={syncPlayback} - onError={handlePlaybackError}/>) : ( -
      - {loading ? '次の投稿を選んでゐます……' : '上映待機中'} -
      )} -
      - -
      -
      -
      - 再生中
      +
      + +
      {post ? ( - - {post.title || post.url} - ) : ( - 未選択)} + { + embedRef.current?.play () + setVideoLength (info.lengthInSeconds * 1_000) + }} + onMetadataChange={syncPlayback} + onError={handlePlaybackError}/>) : ( +
      + {loading ? '次の投稿を選んでゐます……' : '上映待機中'} +
      )}
      - -
      -
      +
      +
      +
      + 再生中 +
      + {post ? ( + + {post.title || post.url} + ) : ( + 未選択)} +
      - {editingPost && ( -
      -
      -
      -

      編集中の投稿

      + {(post && canEditContent (user)) && ( + )} +
      +
      + + {editingPost && ( +
      +
      +

      編輯中の投稿

      - 上映が次へ進んでも、このフォームは {editingPost.title || editingPost.url} - に固定されます。 + を編輯中……

      - -
      + { + setEditingPost (newPost) + if (post?.id === newPost.id) + setPost (newPost) + void refreshWeights () + }}/> +
      )} - { - setEditingPost (newPost) - if (post?.id === newPost.id) - setPost (newPost) - void refreshWeights () - }}/> -
      )} +
      + {commentsPanel} +
      -
      - {commentsPanel} -
      + {layoutMode === 'commentsBottom' && ( +
      + {commentsPanel} +
      )} - {layoutMode === 'commentsBottom' && ( -
      - {commentsPanel} -
      )} +
      + {tagPanel} +
      -
      - {tagPanel} -
      + {layoutMode === 'tagsBottom' && ( +
      + {tagPanel} +
      )} - {layoutMode === 'tagsBottom' && ( -
      - {tagPanel} -
      )} + {historyPanel} + {weightsPanel} - {historyPanel} - {weightsPanel} +
      + {participantsPanel} +
      -
      - {participantsPanel} -
      - - {layoutMode === 'commentsBottom' && ( -
      - {participantsPanel} -
      )} + {layoutMode === 'commentsBottom' && ( +
      + {participantsPanel} +
      )}
@@ -881,17 +905,24 @@ const TheatreDetailPage: FC = ({ user }: Props) => { const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
- {rows.length === 0 ? ( -
候補はありません。
) : rows.slice (0, 8).map (row => ( -
- - {row.post.title || row.post.url} - -
- penalty {row.penalty} - weight {row.weight.toFixed (3)} -
-
))} + {rows.length === 0 + ?
候補はありません。
+ : ( + rows.slice (0, 8).map (row => ( +
+ + {row.post.title || row.post.url} + +
+ penalty {row.penalty} + weight {row.weight.toFixed (3)} +
+
)))}
)