上映会改修 (#302) #357
@@ -12,16 +12,21 @@ BTRC Hub / タグ広場 is a split Rails API and React frontend repository.
|
|||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Backend: Ruby `3.2.2` from `backend/.ruby-version`, Rails `~> 8.0.2`.
|
- 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: 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
|
## Main directories
|
||||||
|
|
||||||
- `backend/app/controllers`: Rails API controllers.
|
- `backend/app/controllers`: Rails API controllers.
|
||||||
- `backend/app/models`: Active Record models.
|
- `backend/app/models`: Active Record models.
|
||||||
- `backend/app/representations`: API response representation classes.
|
- `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/config/routes.rb`: API routes.
|
||||||
- `backend/db/migrate`: migrations.
|
- `backend/db/migrate`: migrations.
|
||||||
- `backend/db/schema.rb`: current schema snapshot.
|
- `backend/db/schema.rb`: current schema snapshot.
|
||||||
@@ -89,7 +94,8 @@ npm run test:run
|
|||||||
npm run preview
|
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.
|
`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: never put a space before method-call parentheses.
|
||||||
- Ruby: do not use `%w` or `%i`.
|
- Ruby: do not use `%w` or `%i`.
|
||||||
- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid.
|
- 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.
|
- Do not add production dependencies without explicit approval.
|
||||||
|
|
||||||
## Backend rules
|
## 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`.
|
- 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.
|
- 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.
|
- Preserve the `X-Transfer-Code` user identification flow unless the task
|
||||||
- Be careful with version tables, `version_no`, optimistic concurrency, wiki revisions, and restore/diff behavior.
|
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.
|
- Be careful with tag names, tag normalization, implications, similarities, and discard behavior.
|
||||||
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
|
- Keep migration files and `backend/db/schema.rb` consistent when changing schema.
|
||||||
|
|
||||||
## Frontend rules
|
## Frontend rules
|
||||||
|
|
||||||
- Use `frontend/src/lib/api.ts` for API calls so headers and camelCase conversion stay consistent.
|
- 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`.
|
- Encode URL path-segment values with `encodeURIComponent`.
|
||||||
- React hooks must be called unconditionally.
|
- 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.
|
- Match existing Tailwind, component, and import alias conventions.
|
||||||
|
|
||||||
### Frontend TSX style
|
### 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.
|
- 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.
|
- Put two blank lines before and after top-level `const` function
|
||||||
- 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.
|
declarations, unless imports, exports, or file boundaries make that awkward.
|
||||||
- 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.
|
- In TSX, indent with 4-space logical indentation.
|
||||||
- Keep JSX closing parentheses in the existing compact style, for example `</div>)` rather than moving `)` onto a separate line.
|
- 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:
|
Preferred:
|
||||||
|
|
||||||
@@ -164,10 +193,14 @@ function PostFormTagsArea ({ tags, setTags }: Props) {
|
|||||||
|
|
||||||
- First inspect existing patterns; do not invent new architecture when a local convention exists.
|
- First inspect existing patterns; do not invent new architecture when a local convention exists.
|
||||||
- Keep changes scoped to the requested issue.
|
- 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.
|
- Do not scan or summarize dependency/generated/runtime directories such as
|
||||||
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication behavior, inspect the related request specs and service objects.
|
`node_modules`, `dist`, `tmp`, `log`, and `storage` unless explicitly needed.
|
||||||
- If frontend code changes, run the existing frontend verification commands that apply: `npm run build`, `npm run lint`, and `npm run test:run`.
|
- Before touching wiki, tag, versioning, BAN, IP BAN, or authentication
|
||||||
- If backend code changes, run the relevant RSpec command; for broad backend changes, run `bundle exec rspec`.
|
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.
|
- If a verification command cannot be run or fails, report the exact command and failure.
|
||||||
|
|
||||||
## Completion criteria
|
## Completion criteria
|
||||||
|
|||||||
+76
-25
@@ -4,7 +4,9 @@
|
|||||||
|
|
||||||
These rules apply to work under `backend/`.
|
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
|
## Commands
|
||||||
|
|
||||||
@@ -50,14 +52,16 @@ If a command cannot be run or fails, report the exact command and failure.
|
|||||||
- `app/controllers`: API controllers.
|
- `app/controllers`: API controllers.
|
||||||
- `app/models`: Active Record models and concerns.
|
- `app/models`: Active Record models and concerns.
|
||||||
- `app/representations`: JSON response shaping.
|
- `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.
|
- `config/routes.rb`: public API routes.
|
||||||
- `db/migrate`: migrations.
|
- `db/migrate`: migrations.
|
||||||
- `db/schema.rb`: schema snapshot.
|
- `db/schema.rb`: schema snapshot.
|
||||||
- `lib/tasks`: custom Rake tasks.
|
- `lib/tasks`: custom Rake tasks.
|
||||||
- `spec`: RSpec tests.
|
- `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
|
## 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.
|
- Use single quotes unless interpolation or escaping makes double quotes better.
|
||||||
- Do not put a space before Ruby method-call parentheses.
|
- Do not put a space before Ruby method-call parentheses.
|
||||||
- Do not use `%w` or `%i` in new Ruby code.
|
- 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.
|
- Keep comments short and useful; avoid narrating obvious code.
|
||||||
- Do not add production dependencies without approval.
|
- Do not add production dependencies without approval.
|
||||||
|
|
||||||
## Authentication and authorization
|
## 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`.
|
- `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.
|
- Do not bypass or weaken the `X-Transfer-Code` flow unless the task
|
||||||
- Unauthenticated write actions should return `:unauthorized` consistently with existing controllers.
|
explicitly changes authentication.
|
||||||
|
- Unauthenticated write actions should return `:unauthorized` consistently
|
||||||
|
with existing controllers.
|
||||||
- Role checks use `User` enum roles: `guest`, `member`, and `admin`.
|
- 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.
|
- Use `current_user.admin?` only for admin-only paths, such as tag child relationship changes.
|
||||||
- Do not replace role checks with looser presence checks.
|
- 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 and IP bans use `banned_at`, not a boolean `banned` column.
|
||||||
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
- `User#banned?` and `IpAddress#banned?` check `banned_at.present?`.
|
||||||
- Do not weaken BAN or IP BAN behavior.
|
- 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
|
## RSpec
|
||||||
|
|
||||||
@@ -99,49 +116,83 @@ Before changing behavior, inspect the matching route, controller, model, service
|
|||||||
- Put Rake task coverage under `spec/tasks`.
|
- Put Rake task coverage under `spec/tasks`.
|
||||||
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
|
- `spec/rails_helper.rb` loads `spec/support/**/*.rb`.
|
||||||
- Request specs include `AuthHelper` and `JsonHelper`.
|
- Request specs include `AuthHelper` and `JsonHelper`.
|
||||||
- `AuthHelper#sign_in_as(user)` stubs `ApplicationController#current_user`; use it when matching existing request spec style.
|
- `AuthHelper#sign_in_as(user)` stubs
|
||||||
- Add or update request specs for API behavior changes, especially status codes, permissions, response shape, and version conflict behavior.
|
`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
|
## Migrations
|
||||||
|
|
||||||
- Keep migrations and `db/schema.rb` consistent.
|
- Keep migrations and `db/schema.rb` consistent.
|
||||||
- Use reversible migrations where practical; otherwise define explicit `up` and `down`.
|
- 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.
|
- Preserve existing indexes, foreign keys, check constraints, and null constraints.
|
||||||
- Be careful with MySQL-specific options already present in migrations, such as `after:`.
|
- 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
|
## Version tables
|
||||||
|
|
||||||
- Versioned records include posts, tags, nico tags, and wiki pages.
|
- 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 event types are `create`, `update`, `discard`, and `restore`.
|
||||||
- Version rows are readonly through the `VersionRecord` concern.
|
- 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`
|
- `PostVersionRecorder`
|
||||||
- `TagVersionRecorder`
|
- `TagVersionRecorder`
|
||||||
- `NicoTagVersionRecorder`
|
- `NicoTagVersionRecorder`
|
||||||
- `WikiVersionRecorder`
|
- `WikiVersionRecorder`
|
||||||
- `TagVersioning`
|
- `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.
|
- 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
|
## Domain cautions
|
||||||
|
|
||||||
- Posts have tag snapshots, parent post implications, original-created ranges, viewed state, and version conflict behavior.
|
- Posts have tag snapshots, parent post implications, original-created ranges,
|
||||||
- Tags have canonical names, aliases through `TagName`, categories, parent implications, discard behavior, and version snapshots.
|
viewed state, and version conflict behavior.
|
||||||
- Nico tags have separate relation/version behavior; do not treat them like normal editable tags without checking existing code.
|
- Tags have canonical names, aliases through `TagName`, categories, parent
|
||||||
- Wiki pages involve page content, revisions/history, version rows, title/tag-name behavior, and diff/restore paths.
|
implications, discard behavior, and version snapshots.
|
||||||
- Materials, theatres, and comments have user and permission checks; inspect the controller before changing them.
|
- 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
|
## API responses
|
||||||
|
|
||||||
- Use representation classes under `app/representations` when existing endpoints do.
|
- 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.
|
- Keep response keys consistent with existing JSON contracts.
|
||||||
- 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.
|
- 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
|
## Files to avoid in routine work
|
||||||
|
|
||||||
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency directories unless explicitly needed.
|
- Do not inspect or edit `tmp/`, `log/`, `storage/`, `vendor/`, or dependency
|
||||||
- Do not modify generated schema or migration output without the corresponding migration when schema changes are made.
|
directories unless explicitly needed.
|
||||||
|
- Do not modify generated schema or migration output without the corresponding
|
||||||
|
migration when schema changes are made.
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ class TheatreProgrammesController < ApplicationController
|
|||||||
programmes = TheatreProgramme
|
programmes = TheatreProgramme
|
||||||
.where(theatre_id: params[:theatre_id])
|
.where(theatre_id: params[:theatre_id])
|
||||||
.where('position > ?', position_gt)
|
.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)
|
.order(position: :desc).limit(100)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class Tag < ApplicationRecord
|
|||||||
|
|
||||||
def material_id = materials.first&.id
|
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.tagme = find_or_create_by_tag_name!('タグ希望', category: :meta)
|
||||||
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
|
def self.bot = find_or_create_by_tag_name!('bot操作', category: :meta)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class TheatrePostSelector
|
class TheatrePostSelector
|
||||||
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
|
Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true)
|
||||||
|
|
||||||
def initialize(theatre:)
|
def initialize theatre:
|
||||||
@theatre = theatre
|
@theatre = theatre
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -20,13 +20,15 @@ class TheatrePostSelector
|
|||||||
candidates.last.post
|
candidates.last.post
|
||||||
end
|
end
|
||||||
|
|
||||||
def weight_json(limit: 20)
|
def weight_json limit: 20
|
||||||
candidates = weighted_candidates
|
candidates = weighted_candidates
|
||||||
sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] }
|
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)),
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -41,7 +43,13 @@ class TheatrePostSelector
|
|||||||
posts.map do |post|
|
posts.map do |post|
|
||||||
post_tags = post.tags.to_a
|
post_tags = post.tags.to_a
|
||||||
penalty = post_tags.sum { |tag| penalties[tag.id].to_i }
|
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
|
end
|
||||||
end
|
end
|
||||||
@@ -73,20 +81,44 @@ class TheatrePostSelector
|
|||||||
return [] if tag_penalties.empty?
|
return [] if tag_penalties.empty?
|
||||||
|
|
||||||
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
|
tags = Tag.where(id: tag_penalties.keys).includes(:tag_name).index_by(&:id)
|
||||||
tag_penalties.map { |tag_id, penalty|
|
|
||||||
|
tag_penalties
|
||||||
|
.map { |tag_id, penalty|
|
||||||
tag = tags[tag_id]
|
tag = tags[tag_id]
|
||||||
next unless tag
|
next unless tag
|
||||||
|
|
||||||
{ tag: TagRepr.inline(tag), penalty: }
|
{
|
||||||
}.compact.sort_by { |row| [-row[:penalty], row[:tag]['name'].to_s] }
|
tag: light_tag_json(tag),
|
||||||
|
penalty:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.compact
|
||||||
|
.sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_weight_json(candidates)
|
def post_weight_json candidates
|
||||||
candidates.map { |candidate|
|
candidates.map { |candidate|
|
||||||
{ post: PostRepr.base(candidate.post),
|
{
|
||||||
|
post: light_post_json(candidate.post),
|
||||||
weight: candidate.weight,
|
weight: candidate.weight,
|
||||||
penalty: candidate.penalty,
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
+64
-16
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
These rules apply to work under `frontend/`.
|
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
|
## Commands
|
||||||
|
|
||||||
@@ -17,9 +18,11 @@ npm run lint
|
|||||||
npm run preview
|
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:
|
After frontend changes, run:
|
||||||
|
|
||||||
@@ -32,18 +35,33 @@ If either command cannot be run or fails, report the exact command and failure.
|
|||||||
|
|
||||||
## TypeScript
|
## 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.
|
- Keep types explicit at module boundaries, API helpers, and exported utilities.
|
||||||
- Use `import type` for type-only imports.
|
- Use `import type` for type-only imports.
|
||||||
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
|
- 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.
|
- 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
|
## React
|
||||||
|
|
||||||
- Use function components.
|
- 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.
|
- 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 page-level components under `src/pages`.
|
||||||
- Keep shared and feature components under `src/components`.
|
- Keep shared and feature components under `src/components`.
|
||||||
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
|
- 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
|
## TanStack Query
|
||||||
|
|
||||||
- Use `@tanstack/react-query` for server state.
|
- 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.
|
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
|
||||||
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
|
instead of using ad hoc arrays in components.
|
||||||
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
|
- Fetch functions should live in domain helpers under `src/lib`, such as
|
||||||
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
|
`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
|
## API calls
|
||||||
|
|
||||||
- Use `src/lib/api.ts` for HTTP 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.
|
- 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.
|
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
|
||||||
|
|
||||||
## Imports and aliases
|
## 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}`.
|
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
|
||||||
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
|
- 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.
|
- 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.
|
- 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
|
## 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.
|
- 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`.
|
- `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
|
## Files to avoid in routine work
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types'
|
|||||||
|
|
||||||
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
||||||
|
|
||||||
|
|
||||||
export const canEditContent = (
|
export const canEditContent = (
|
||||||
user: Pick<User, 'role'> | null | undefined,
|
user: Pick<User, 'role'> | null | undefined,
|
||||||
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SITE_TITLE } from '@/config'
|
|||||||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
|
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
|
||||||
import { fetchPost } from '@/lib/posts'
|
import { fetchPost } from '@/lib/posts'
|
||||||
|
import { canEditContent } from '@/lib/users'
|
||||||
import { cn, dateString, inputClass } from '@/lib/utils'
|
import { cn, dateString, inputClass } from '@/lib/utils'
|
||||||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||||||
|
|
||||||
@@ -42,8 +43,7 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
|
|||||||
postStartedAt: null,
|
postStartedAt: null,
|
||||||
postElapsedMs: null,
|
postElapsedMs: null,
|
||||||
watchingUsers: [],
|
watchingUsers: [],
|
||||||
skipVote: {
|
skipVote: { votesCount: 0,
|
||||||
votesCount: 0,
|
|
||||||
requiredCount: 1,
|
requiredCount: 1,
|
||||||
watchingUsersCount: 0,
|
watchingUsersCount: 0,
|
||||||
voted: false } }
|
voted: false } }
|
||||||
@@ -56,12 +56,12 @@ const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
|||||||
|
|
||||||
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
||||||
threeColumns: '3 列',
|
threeColumns: '3 列',
|
||||||
tagsBottom: '2 列(コメント欄)',
|
tagsBottom: '2 列 A 型',
|
||||||
commentsBottom: '2 列(タグ欄)' }
|
commentsBottom: '2 列 B 型' }
|
||||||
|
|
||||||
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
|
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
|
||||||
vertical: 'タグ縦',
|
vertical: '縦並び',
|
||||||
horizontal: 'タグ横' }
|
horizontal: '横並び' }
|
||||||
|
|
||||||
|
|
||||||
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
|
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
|
||||||
@@ -88,13 +88,13 @@ const commentBox = (
|
|||||||
</div>),
|
</div>),
|
||||||
(
|
(
|
||||||
<div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
|
<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">
|
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
|
||||||
{programme.post.title || programme.post.url}
|
{programme.post.title || programme.post.url}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
</>) : 'この時の動画:履歴外'}
|
 へのコメント
|
||||||
|
</>)}
|
||||||
</div>)]
|
</div>)]
|
||||||
|
|
||||||
|
|
||||||
@@ -124,6 +124,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
const grouped = tagsByCategory (tags)
|
const grouped = tagsByCategory (tags)
|
||||||
|
|
||||||
if (flow === 'horizontal')
|
if (flow === 'horizontal')
|
||||||
|
{
|
||||||
return (
|
return (
|
||||||
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
||||||
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
||||||
@@ -131,6 +132,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|||||||
<TagLink tag={tag} withCount={false}/>
|
<TagLink tag={tag} withCount={false}/>
|
||||||
</li>))}
|
</li>))}
|
||||||
</ul>)
|
</ul>)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<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">
|
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
|
||||||
{CATEGORY_NAMES[cat]}
|
{CATEGORY_NAMES[cat]}
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
||||||
className={cn (
|
|
||||||
'space-y-1',
|
|
||||||
compact && 'text-sm')}>
|
|
||||||
{rows.map (tag => (
|
{rows.map (tag => (
|
||||||
<li key={tag.id} className="text-left leading-tight">
|
<li key={tag.id} className="text-left leading-tight">
|
||||||
<TagLink tag={tag} withCount={false}/>
|
<TagLink tag={tag} withCount={false}/>
|
||||||
@@ -188,16 +187,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
|
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
|
||||||
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
|
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
|
||||||
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
|
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
|
||||||
return (['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
|
return (
|
||||||
.includes (stored as TheatreLayoutMode)
|
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
|
||||||
? stored as TheatreLayoutMode
|
.includes (stored as TheatreLayoutMode))
|
||||||
: 'threeColumns'
|
? (stored as TheatreLayoutMode)
|
||||||
|
: 'threeColumns')
|
||||||
})
|
})
|
||||||
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
|
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
|
||||||
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
|
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
|
||||||
return (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
|
return (
|
||||||
? stored as TagFlow
|
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
|
||||||
: 'vertical'
|
? (stored as TagFlow)
|
||||||
|
: 'vertical')
|
||||||
})
|
})
|
||||||
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
||||||
useValidationErrors<TheatreCommentField> ()
|
useValidationErrors<TheatreCommentField> ()
|
||||||
@@ -217,7 +218,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
setTheatreInfo (nextInfo)
|
setTheatreInfo (nextInfo)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const currentPostElapsedMs = useCallback ((info: TheatreInfo = theatreInfoRef.current): number => {
|
const currentPostElapsedMs = useCallback (
|
||||||
|
(info: TheatreInfo = theatreInfoRef.current): number => {
|
||||||
if (info.postElapsedMs == null)
|
if (info.postElapsedMs == null)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -352,11 +354,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
if (ended)
|
if (ended)
|
||||||
{
|
{
|
||||||
if (!(cancelled))
|
if (!(cancelled))
|
||||||
|
{
|
||||||
setTheatreInfo (prev => ({
|
setTheatreInfo (prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
postId: null,
|
postId: null,
|
||||||
postStartedAt: null,
|
postStartedAt: null,
|
||||||
postElapsedMs: null }))
|
postElapsedMs: null }))
|
||||||
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -561,7 +565,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
return <ErrorScreen status={status}/>
|
return <ErrorScreen status={status}/>
|
||||||
|
|
||||||
const tagPanel = (
|
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">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
<h2 className="font-bold">タグ</h2>
|
<h2 className="font-bold">タグ</h2>
|
||||||
{layoutMode === 'tagsBottom' && (
|
{layoutMode === 'tagsBottom' && (
|
||||||
@@ -583,7 +587,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
const commentsPanel = (
|
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>
|
<h2 className="mb-3 font-bold">コメント</h2>
|
||||||
<form onSubmit={handleCommentSubmit}>
|
<form onSubmit={handleCommentSubmit}>
|
||||||
<input
|
<input
|
||||||
@@ -598,18 +602,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
ref={commentsRef}
|
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 => {
|
{comments.map (comment => {
|
||||||
const commentProgramme = programmeForComment (comment)
|
const commentProgramme = programmeForComment (comment)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.no}
|
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)) && (
|
{(user && comment.user?.id === user.id && !(comment.deleted)) && (
|
||||||
<button
|
<button
|
||||||
type="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="コメントを削除"
|
aria-label="コメントを削除"
|
||||||
onClick={async e => {
|
onClick={async e => {
|
||||||
e.stopPropagation ()
|
e.stopPropagation ()
|
||||||
@@ -617,7 +624,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
if (!(await dialogue.confirm ({
|
if (!(await dialogue.confirm ({
|
||||||
title: 'このコメントを削除しますか?',
|
title: 'このコメントを削除しますか?',
|
||||||
description: (
|
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)}
|
{commentBox (comment, commentProgramme)}
|
||||||
</div>),
|
</div>),
|
||||||
confirmText: '削除',
|
confirmText: '削除',
|
||||||
@@ -635,7 +643,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
const participantsPanel = (
|
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>
|
<h2 className="mb-3 font-bold">参加者</h2>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{theatreInfo.watchingUsers.map (watchingUser => (
|
{theatreInfo.watchingUsers.map (watchingUser => (
|
||||||
@@ -647,43 +655,56 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
const historyPanel = (
|
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>
|
<h2 className="mb-3 font-bold">再生履歴</h2>
|
||||||
<div className="max-h-72 overflow-y-auto">
|
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
|
||||||
{programmes.length === 0 ? (
|
overflow-y-auto">
|
||||||
<div className="text-sm text-zinc-500">まだ履歴はありません。</div>) : programmes.map (programme => (
|
{programmes.length === 0
|
||||||
<div key={`${ programme.theatreId }-${ programme.position }`} className="border-t border-zinc-100 py-2 text-sm first:border-t-0 dark:border-zinc-800">
|
? <div className="text-sm text-zinc-500">まだ履歴はありません。</div>
|
||||||
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
|
: (
|
||||||
|
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}
|
{programme.post.title || programme.post.url}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<div className="text-xs text-zinc-500">
|
<div className="text-xs text-zinc-500">
|
||||||
#{programme.position} / {dateString (programme.createdAt)}
|
{dateString (programme.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>))}
|
</div>)))}
|
||||||
</div>
|
</div>
|
||||||
</section>)
|
</section>)
|
||||||
|
|
||||||
const weightsPanel = (
|
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">
|
<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 type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
|
||||||
更新
|
更新
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
<div className="mx-4 grid gap-16 xl:grid-cols-3">
|
||||||
<div>
|
<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">
|
<div className="space-y-1 text-sm">
|
||||||
{weights.tagPenalties.length === 0 ? (
|
{weights.tagPenalties.length === 0
|
||||||
<div className="text-zinc-500">まだ減点はありません。</div>) : weights.tagPenalties.slice (0, 12).map (row => (
|
? <div className="text-zinc-500">まだ減点はありません。</div>
|
||||||
<div key={row.tag.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2 text-left">
|
: (
|
||||||
|
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">
|
<div className="min-w-0 text-left">
|
||||||
<TagLink tag={row.tag} withCount={false}/>
|
<TagLink tag={row.tag} withCount={false}/>
|
||||||
</div>
|
</div>
|
||||||
<span className="font-mono">{row.penalty}</span>
|
<span className="font-mono">{row.penalty}</span>
|
||||||
</div>))}
|
</div>)))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -710,14 +731,19 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className={cn (
|
<div className={cn (
|
||||||
'grid min-h-full gap-4 overflow-visible p-3 md:h-full md:overflow-hidden',
|
'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 === 'threeColumns'
|
||||||
layoutMode === 'tagsBottom' && 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]',
|
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
|
||||||
layoutMode === 'commentsBottom' && 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]')}>
|
'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' && (
|
{layoutMode !== 'tagsBottom' && (
|
||||||
<motion.aside
|
<motion.aside
|
||||||
layout="position"
|
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]">
|
<div className="md:[direction:ltr]">
|
||||||
{tagPanel}
|
{tagPanel}
|
||||||
</div>
|
</div>
|
||||||
@@ -725,12 +751,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
|
|
||||||
<motion.main
|
<motion.main
|
||||||
layout="position"
|
layout="position"
|
||||||
className={cn (
|
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
|
||||||
'order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
|
|
||||||
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
|
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
|
||||||
<div className={cn (layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
||||||
<section className="overflow-hidden rounded border border-zinc-300 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
<section className="overflow-hidden rounded border-zinc-300
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
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>
|
<div>
|
||||||
<h1 className="text-lg font-bold">{theatreTitle}</h1>
|
<h1 className="text-lg font-bold">{theatreTitle}</h1>
|
||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
@@ -764,7 +791,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-center bg-black">
|
<div className="flex justify-center bg-black mx-4">
|
||||||
{post ? (
|
{post ? (
|
||||||
<PostEmbed
|
<PostEmbed
|
||||||
key={post.id}
|
key={post.id}
|
||||||
@@ -787,44 +814,41 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
再生中
|
再生中
|
||||||
</div>
|
</div>
|
||||||
{post ? (
|
{post ? (
|
||||||
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold hover:underline">
|
<PrefetchLink
|
||||||
|
to={`/posts/${ post.id }`}
|
||||||
|
className="font-bold hover:underline">
|
||||||
{post.title || post.url}
|
{post.title || post.url}
|
||||||
</PrefetchLink>) : (
|
</PrefetchLink>) : (
|
||||||
<span className="text-zinc-500">未選択</span>)}
|
<span className="text-zinc-500">未選択</span>)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(post && canEditContent (user)) && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={!(post)}
|
disabled={!(post)}
|
||||||
onClick={() => post && setEditingPost (post)}>
|
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
|
||||||
この投稿を編集
|
{editingPost ? '閉じる' : '編輯'}
|
||||||
</Button>
|
</Button>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{editingPost && (
|
{editingPost && (
|
||||||
<section className="rounded border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
|
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
|
||||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
dark:border-amber-800 dark:bg-amber-950/30">
|
||||||
<div>
|
<div className="mb-3">
|
||||||
<h2 className="font-bold">編集中の投稿</h2>
|
<h2 className="font-bold">編輯中の投稿</h2>
|
||||||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||||||
上映が次へ進んでも、このフォームは
|
|
||||||
<PrefetchLink
|
<PrefetchLink
|
||||||
to={`/posts/${ editingPost.id }`}
|
to={`/posts/${ editingPost.id }`}
|
||||||
className="mx-1 font-bold underline">
|
className="mx-1 font-bold underline">
|
||||||
{editingPost.title || editingPost.url}
|
{editingPost.title || editingPost.url}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
に固定されます。
|
を編輯中……
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => setEditingPost (null)}>
|
|
||||||
閉じる
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PostEditForm
|
<PostEditForm
|
||||||
post={editingPost}
|
post={editingPost}
|
||||||
onSave={newPost => {
|
onSave={newPost => {
|
||||||
@@ -881,17 +905,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|||||||
|
|
||||||
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
|
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-2 text-sm">
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0
|
||||||
<div className="text-zinc-500">候補はありません。</div>) : rows.slice (0, 8).map (row => (
|
? <div className="text-zinc-500">候補はありません。</div>
|
||||||
<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">
|
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}
|
{row.post.title || row.post.url}
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<div className="flex justify-between gap-2 text-xs text-zinc-500">
|
<div className="flex justify-between gap-2 text-xs text-zinc-500">
|
||||||
<span>penalty {row.penalty}</span>
|
<span>penalty {row.penalty}</span>
|
||||||
<span>weight {row.weight.toFixed (3)}</span>
|
<span>weight {row.weight.toFixed (3)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>))}
|
</div>)))}
|
||||||
</div>)
|
</div>)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする