From 09763982b5fd8fe7c5787b0593364251469302fe Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 17 May 2026 21:09:43 +0900 Subject: [PATCH 01/12] #302 --- backend/app/controllers/posts_controller.rb | 12 +- .../theatre_comments_controller.rb | 25 +++- .../theatre_programmes_controller.rb | 17 +++ .../app/controllers/theatres_controller.rb | 13 +- backend/app/models/tag.rb | 2 +- backend/app/models/theatre.rb | 2 + backend/app/models/theatre_programme.rb | 6 + backend/config/routes.rb | 3 +- ...0260514221900_create_theatre_programmes.rb | 10 ++ backend/db/schema.rb | 14 +- frontend/src/App.tsx | 4 +- frontend/src/components/TopNav.tsx | 10 +- .../src/pages/theatres/TheatreDetailPage.tsx | 135 +++++++++++++++--- frontend/src/types.ts | 23 ++- 14 files changed, 231 insertions(+), 45 deletions(-) create mode 100644 backend/app/controllers/theatre_programmes_controller.rb create mode 100644 backend/app/models/theatre_programme.rb create mode 100644 backend/db/migrate/20260514221900_create_theatre_programmes.rb diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index e8cae00..3edabdb 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -44,7 +44,8 @@ class PostsController < ApplicationController filtered_posts .joins("LEFT JOIN (#{ pt_max_sql }) pt_max ON pt_max.post_id = posts.id") .reselect('posts.*', Arel.sql("#{ updated_at_all_sql } AS updated_at_all")) - .preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) + .preload(:parents, :children, + tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .with_attached_thumbnail q = q.where('posts.url LIKE ?', "%#{ url }%") if url @@ -95,7 +96,8 @@ class PostsController < ApplicationController end def random - post = filtered_posts.preload(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) + post = filtered_posts.preload(:parents, :childern, + tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) .order('RAND()') .first return head :not_found unless post @@ -104,7 +106,11 @@ class PostsController < ApplicationController end def show - post = Post.includes(tags: [:deerjikists, :materials, { tag_name: :wiki_page }]).find_by(id: params[:id]) + post = Post + .preload(:parents, :children) + .includes(:parents, :children, + tags: [:deerjikists, :materials, { tag_name: :wiki_page }]) + .find_by(id: params[:id]) return head :not_found unless post render json: PostRepr.base(post, current_user) diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb index 50ec9ef..e58aa5b 100644 --- a/backend/app/controllers/theatre_comments_controller.rb +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -1,14 +1,21 @@ class TheatreCommentsController < ApplicationController def index + limit = params[:limit].to_i + limit = 20 if limit <= 0 + no_gt = params[:no_gt].to_i - no_gt = 0 if no_gt.negative? + no_gt = 0 if no_gt < 0 comments = TheatreComment .where(theatre_id: params[:theatre_id]) .where('no > ?', no_gt) .order(no: :desc) + .limit(limit) - render json: comments.as_json(include: { user: { only: [:id, :name] } }) + render json: comments.map { + _1.as_json(include: { user: { only: [:id, :name] } }) + .merge(content: _1.discarded? ? nil : _1.content, deleted: _1.discarded?) + } end def create @@ -29,4 +36,18 @@ class TheatreCommentsController < ApplicationController render json: comment, status: :created end + + def destroy + return head :unauthorized unless current_user + + theatre_id = params[:theatre_id].to_i + no = params[:id].to_i + + comment = TheatreComment.find_by(theatre_id:, no:) + return head :not_found unless comment + + comment.discard! + + head :no_content + end end diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb new file mode 100644 index 0000000..8aadcd6 --- /dev/null +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -0,0 +1,17 @@ +class TheatreProgrammesController < ApplicationController + def index + limit = params[:limit].to_i + limit = 100 if limit <= 0 + + position_gt = params[:position_gt].to_i + position_gt = 0 if position_gt < 0 + + programmes = TheatreProgramme + .where(theatre_id: params[:theatre_id]) + .where('position > ?', position_gt) + .order(position: :desc).limit(100) + .limit(limit) + + render json: programmes.as_json(include: { post: PostRepr::BASE }) + end +end diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 7949045..3b74718 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -43,11 +43,14 @@ class TheatresController < ApplicationController return head :not_found unless theatre return head :forbidden if theatre.host_user != current_user - post = Post.where("url LIKE '%nicovideo.jp%'") - .or(Post.where("url LIKE '%youtube.com%'")) - .order('RAND()') - .first - theatre.update!(current_post: post, current_post_started_at: Time.current) + ApplicationRecord.transaction do + post = Post.where("url LIKE '%nicovideo.jp%'") + .order('RAND()') + .first + theatre.update!(current_post: post, current_post_started_at: Time.current) + position = (theatre.programmes.maximum(:position) || 0) + 1 + theatre.programmes.create!(position:, post:) + end head :no_content end diff --git a/backend/app/models/tag.rb b/backend/app/models/tag.rb index 51ca783..cad85e9 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.present? + def has_deerjikists = 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/models/theatre.rb b/backend/app/models/theatre.rb index 1da8555..3fbdc6a 100644 --- a/backend/app/models/theatre.rb +++ b/backend/app/models/theatre.rb @@ -7,6 +7,8 @@ class Theatre < ApplicationRecord class_name: 'TheatreWatchingUser', inverse_of: :theatre has_many :watching_users, through: :active_theatre_watching_users, source: :user + has_many :programmes, class_name: 'TheatreProgramme' + belongs_to :host_user, class_name: 'User', optional: true belongs_to :current_post, class_name: 'Post', optional: true belongs_to :created_by_user, class_name: 'User' diff --git a/backend/app/models/theatre_programme.rb b/backend/app/models/theatre_programme.rb new file mode 100644 index 0000000..58e2298 --- /dev/null +++ b/backend/app/models/theatre_programme.rb @@ -0,0 +1,6 @@ +class TheatreProgramme < ApplicationRecord + self.primary_key = :theatre_id, :position + + belongs_to :theatre + belongs_to :post +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 6e62f1e..dfe2f92 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -87,7 +87,8 @@ Rails.application.routes.draw do patch :next_post end - resources :comments, controller: :theatre_comments, only: [:index, :create] + resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] + resources :programmes, controller: :theatre_programmes, only: [:index] end resources :materials, only: [:index, :show, :create, :update, :destroy] diff --git a/backend/db/migrate/20260514221900_create_theatre_programmes.rb b/backend/db/migrate/20260514221900_create_theatre_programmes.rb new file mode 100644 index 0000000..f89f04b --- /dev/null +++ b/backend/db/migrate/20260514221900_create_theatre_programmes.rb @@ -0,0 +1,10 @@ +class CreateTheatreProgrammes < ActiveRecord::Migration[8.0] + def change + create_table :theatre_programmes, primary_key: [:theatre_id, :position] do |t| + t.references :theatre, null: false, foreign_key: true + t.integer :position, null: false + t.references :post, null: false, foreign_key: true + t.datetime :created_at, null: false + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 94edb82..b5e2118 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do +ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -283,6 +283,15 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do t.index ["user_id"], name: "index_theatre_comments_on_user_id" end + create_table "theatre_programmes", primary_key: ["theatre_id", "position"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "theatre_id", null: false + t.integer "position", null: false + t.bigint "post_id", null: false + t.datetime "created_at", null: false + t.index ["post_id"], name: "index_theatre_programmes_on_post_id" + t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id" + end + create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "theatre_id", null: false t.bigint "user_id", null: false @@ -290,6 +299,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" + t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" @@ -464,6 +474,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_07_213300) do add_foreign_key "tags", "tag_names" add_foreign_key "theatre_comments", "theatres" add_foreign_key "theatre_comments", "users" + add_foreign_key "theatre_programmes", "posts" + add_foreign_key "theatre_programmes", "theatres" add_foreign_key "theatre_watching_users", "theatres" add_foreign_key "theatre_watching_users", "users" add_foreign_key "theatres", "posts", column: "current_post_id" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2eeaf79..553c10d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -63,7 +63,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { }/> }/> }/> - }/> + }/> }> }/> }/> @@ -158,4 +158,4 @@ const App: FC = () => { ) } -export default App \ No newline at end of file +export default App diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index c7772bb..12235fe 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -55,12 +55,6 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { { name: '追加', to: '/materials/new' }, { name: '全体履歴', to: '/materials/changes', visible: false }, { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, - { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - { name: <>第 1 会場, to: '/theatres/1' }, - { name: 'CyTube', to: '//cytube.mm428.net/r/deernijika' }, - { name: <>ニジカ放送局第 1 チャンネル, - to: '//www.youtube.com/watch?v=DCU3hL4Uu6A' }, - { name: 'ヘルプ', to: '/wiki/ヘルプ:上映会' }] }, { name: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -71,6 +65,8 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, + { name: 'おたのしみ', visible: false, subMenu: [ + { name: '上映会 (β)', to: '/theatres/1' }] }, { name: 'ユーザ', to: '/users/settings', visible: false, subMenu: [ { name: '一覧', to: '/users', visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false }, @@ -267,7 +263,7 @@ const TopNav: FC = ({ user }) => { : { initial: { x: 40, y: -40, opacity: 0 }, animate: { x: 0, y: 0, opacity: 1 }, exit: { x: 40, y: -40, opacity: 0 } })} - className="z-10 h-full flex items-center px-3 font-bold w-24"> + className="z-10 h-full flex items-center px-3 font-bold w-28">

{item.name}

{item.subMenu diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 0b626bd..7d647e6 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -6,20 +6,24 @@ import ErrorScreen from '@/components/ErrorScreen' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' import TagDetailSidebar from '@/components/TagDetailSidebar' +import SectionTitle from '@/components/common/SectionTitle' +import { useDialogue } from '@/components/dialogues/DialogueProvider' +import { Button } from '@/components/ui/button' import MainArea from '@/components/layout/MainArea' import SidebarComponent from '@/components/layout/SidebarComponent' import { SITE_TITLE } from '@/config' -import { apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' +import { apiGet, apiDelete, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { fetchPost } from '@/lib/posts' import { dateString } from '@/lib/utils' -import type { FC } from 'react' +import type { FC, User } from 'react' import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre, - TheatreComment } from '@/types' + TheatreComment, + TheatreProgramme } from '@/types' type TheatreInfo = { hostFlg: boolean @@ -34,9 +38,36 @@ const INITIAL_THEATRE_INFO = watchingUsers: [] as { id: number; name: string }[] } as const -const TheatreDetailPage: FC = () => { +const commentBox: ReactNode[] = (comment: TheatreComment) => + [( +
+ {comment.deleted + ? ( + + 削除されました. + ) + : comment.content} +
), + ( +
+ by {comment.user + ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) + : '運営'} +
), + ( +
+ {dateString (comment.createdAt)} +
)] + + +type Props = { user: User } + + +const TheatreDetailPage: FC = ({ user }: Props) => { const { id } = useParams () + const dialogue = useDialogue () + const commentsRef = useRef (null) const embedRef = useRef (null) const loadingRef = useRef (false) @@ -52,6 +83,7 @@ const TheatreDetailPage: FC = () => { const [theatre, setTheatre] = useState (null) const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) + const [programmes, setProgrammes] = useState ([]) const [videoLength, setVideoLength] = useState (0) useEffect (() => { @@ -118,7 +150,7 @@ const TheatreDetailPage: FC = () => { { const newComments = await apiGet ( `/theatres/${ id }/comments`, - { params: { no_gt: lastCommentNoRef.current } }) + { params: { no_gt: lastCommentNoRef.current, limit: '20' } }) if (!(cancelled) && newComments.length > 0) { @@ -214,10 +246,24 @@ const TheatreDetailPage: FC = () => { } }) () + void (async () => { + try + { + const data = await apiGet ( + `/theatres/${ id }/programmes`, { params: { limit: '100' } }) + if (!(cancelled)) + setProgrammes (data) + } + catch (e) + { + console.error (e) + } + }) () + return () => { cancelled = true } - }, [theatreInfo.postId]) + }, [id, theatreInfo.postId]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) @@ -233,12 +279,30 @@ const TheatreDetailPage: FC = () => { embedRef.current?.seek (targetTime) } + const handleDelete = async (commentNo: number) => { + try + { + await apiDelete (`/theatres/${ id }/comments/${ commentNo }`) + setComments (prev => { + const rtn = [...prev] + const idx = rtn.findIndex (x => x.no === commentNo) + rtn[idx] = { ...rtn[idx], deleted: true } + return rtn + }) + } + catch + { + ; + } + } + if (status >= 400) return return (
+ {theatre && ( {'上映会場' @@ -270,6 +334,22 @@ const TheatreDetailPage: FC = () => { </PrefetchLink> </div> </>) : 'Loading...'} + + <div> + <SectionTitle> + 上映履歴 + </SectionTitle> + + <div> + {programmes.map ((programme, i) => ( + <div key={i}> + <PrefetchLink to={`/posts/${ programme.post.id }`}> + {programme.post.title} + </PrefetchLink> + ({dateString (programme.createdAt)}) + </div>))} + </div> + </div> </MainArea> <SidebarComponent> @@ -306,18 +386,36 @@ const TheatreDetailPage: FC = () => { className="overflow-x-hidden overflow-y-scroll text-wrap w-full h-[32vh] md:h-[64vh] border rounded"> {comments.map (comment => ( - <div key={comment.no} className="p-2"> - <div className="w-full"> - {comment.content} - </div> - <div className="w-full text-sm text-right"> - by {comment.user - ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) - : '運営'} - </div> - <div className="w-full text-sm text-right"> - {dateString (comment.createdAt)} - </div> + <div + key={comment.no} + className="p-2 group relative rounded py-1 hover:bg-gray-100 + dark:hover:bg-gray-800"> + {(user && comment.user?.id === user.id && !(comment.deleted)) && ( + <button + type="button" + className="absolute left-1 top-1 hidden rounded text-md text-red-600 + hover:bg-red-100 group-hover:inline-block + dark:text-red-300 dark:hover:bg-red-950" + aria-label="コメントを削除" + onClick={async e => { + e.stopPropagation () + + if (!(await dialogue.confirm ({ + title: 'このコメントを削除しますか?', + description: ( + <div className="border border-black dark:border-white rounded + my-3 p-2 w-64"> + {commentBox (comment)} + </div>), + confirmText: '削除', + variant: 'danger' }))) + return + + await handleDelete (comment.no) + }}> + × + </button>)} + {commentBox (comment)} </div>))} </div> </form> @@ -345,4 +443,5 @@ const TheatreDetailPage: FC = () => { </div>) } + export default TheatreDetailPage diff --git a/frontend/src/types.ts b/frontend/src/types.ts index e552f69..c388add 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -210,11 +210,24 @@ export type Theatre = { createdAt: string updatedAt: string } -export type TheatreComment = { - theatreId: number, - no: number, - user: { id: number, name: string } | null - content: string +export type TheatreComment = + | { theatreId: number + no: number + deteled: false + user: { id: number, name: string } | null + content: string + createdAt: string } + | { theatreId: number + no: number + deleted: true + user: { id: number, name: string } | null + content null, + createdAt: string } + +export type TheatreProgramme = { + theatreId: number + position: number + post: Post createdAt: string } export type User = { -- 2.34.1 From 81e620c33a5748652c9f96317003bba7303d56e8 Mon Sep 17 00:00:00 2001 From: miteruzo <miteruzo@naver.com> Date: Sat, 6 Jun 2026 19:49:21 +0900 Subject: [PATCH 02/12] #302 --- .../theatre_programmes_controller.rb | 4 +- .../theatre_skip_events_controller.rb | 24 + .../app/controllers/theatres_controller.rb | 97 +- backend/app/models/theatre.rb | 2 + backend/app/models/theatre_skip_event.rb | 10 + backend/app/models/theatre_skip_event_tag.rb | 6 + .../app/models/theatre_skip_event_voter.rb | 6 + backend/app/models/theatre_skip_vote.rb | 7 + backend/app/services/theatre_post_advancer.rb | 29 + backend/app/services/theatre_post_selector.rb | 92 ++ .../app/services/theatre_skip_finalizer.rb | 40 + backend/config/routes.rb | 4 + ...00_create_theatre_skip_votes_and_events.rb | 36 + backend/db/schema.rb | 70 +- .../spec/requests/theatre_programmes_spec.rb | 31 + backend/spec/requests/theatres_spec.rb | 144 ++- frontend/src/components/NicoViewer.tsx | 12 +- frontend/src/components/PostEmbed.tsx | 8 +- frontend/src/components/TopNav.tsx | 10 +- frontend/src/lib/api.ts | 9 +- .../src/pages/theatres/TheatreDetailPage.tsx | 850 +++++++++++++----- frontend/src/types.ts | 44 +- 22 files changed, 1290 insertions(+), 245 deletions(-) create mode 100644 backend/app/controllers/theatre_skip_events_controller.rb create mode 100644 backend/app/models/theatre_skip_event.rb create mode 100644 backend/app/models/theatre_skip_event_tag.rb create mode 100644 backend/app/models/theatre_skip_event_voter.rb create mode 100644 backend/app/models/theatre_skip_vote.rb create mode 100644 backend/app/services/theatre_post_advancer.rb create mode 100644 backend/app/services/theatre_post_selector.rb create mode 100644 backend/app/services/theatre_skip_finalizer.rb create mode 100644 backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb create mode 100644 backend/spec/requests/theatre_programmes_spec.rb diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb index 8aadcd6..b8b9dd5 100644 --- a/backend/app/controllers/theatre_programmes_controller.rb +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -12,6 +12,8 @@ class TheatreProgrammesController < ApplicationController .order(position: :desc).limit(100) .limit(limit) - render json: programmes.as_json(include: { post: PostRepr::BASE }) + render json: programmes.map { |programme| + programme.as_json.merge(post: PostRepr.base(programme.post)) + } end end diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb new file mode 100644 index 0000000..4ee35da --- /dev/null +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -0,0 +1,24 @@ +class TheatreSkipEventsController < ApplicationController + def index + limit = params[:limit].to_i + limit = 50 if limit <= 0 + + events = + TheatreSkipEvent + .where(theatre_id: params[:theatre_id]) + .includes(:skipped_by_user, :users, :tags, post: { tags: :tag_name }) + .order(created_at: :desc) + .limit(limit) + + render json: events.map { |event| + { id: event.id, + theatre_id: event.theatre_id, + post: PostRepr.base(event.post), + skipped_by_user: UserRepr.base(event.skipped_by_user), + voters: event.users.map { |user| UserRepr.base(user) }, + tags: event.tags.map { |tag| TagRepr.inline(tag) }, + programme_position: event.programme_position, + created_at: event.created_at } + } + end +end diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 3b74718..8c864e2 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -31,9 +31,7 @@ class TheatresController < ApplicationController post_started_at = theatre.current_post_started_at end - render json: { - host_flg:, post_id:, post_started_at:, - watching_users: theatre.watching_users.as_json(only: [:id, :name]) } + render json: theatre_info_json(theatre, host_flg:, post_id:, post_started_at:) end def next_post @@ -44,14 +42,95 @@ class TheatresController < ApplicationController return head :forbidden if theatre.host_user != current_user ApplicationRecord.transaction do - post = Post.where("url LIKE '%nicovideo.jp%'") - .order('RAND()') - .first - theatre.update!(current_post: post, current_post_started_at: Time.current) - position = (theatre.programmes.maximum(:position) || 0) + 1 - theatre.programmes.create!(position:, post:) + theatre.lock! + TheatrePostAdvancer.call(theatre:) end head :no_content end + + def skip_vote + return head :unauthorized unless current_user + + theatre = Theatre.find_by(id: params[:id]) + return head :not_found unless theatre + + skipped = false + + ApplicationRecord.transaction do + theatre.lock! + if theatre.current_post + TheatreWatchingUser.find_or_initialize_by(theatre:, user: current_user).tap { + _1.expires_at = 30.seconds.from_now + }.save! + + TheatreSkipVote.find_or_create_by!(theatre:, post: theatre.current_post, user: current_user) + + vote_status = skip_vote_status(theatre) + if vote_status[:votes_count] >= vote_status[:required_count] + TheatreSkipFinalizer.call(theatre:, user: current_user) + TheatrePostAdvancer.call(theatre:) + skipped = true + end + end + end + + theatre.reload + render json: theatre_info_json(theatre, skipped:) + end + + def unskip_vote + return head :unauthorized unless current_user + + theatre = Theatre.find_by(id: params[:id]) + return head :not_found unless theatre + + if theatre.current_post + TheatreSkipVote.where(theatre:, post: theatre.current_post, user: current_user).delete_all + end + + render json: theatre_info_json(theatre, skipped: false) + end + + def post_selection_weights + theatre = Theatre.find_by(id: params[:id]) + return head :not_found unless theatre + + render json: TheatrePostSelector.new(theatre:).weight_json + end + + private + + def theatre_info_json(theatre, host_flg: nil, post_id: nil, post_started_at: nil, skipped: nil) + host_flg = theatre.host_user_id == current_user&.id if host_flg.nil? + post_id = theatre.current_post_id if post_id.nil? + post_started_at = theatre.current_post_started_at if post_started_at.nil? + + json = { host_flg:, + post_id:, + post_started_at:, + post_elapsed_ms: post_started_at ? ((Time.current - post_started_at) * 1000).floor : nil, + watching_users: theatre.watching_users.as_json(only: [:id, :name]), + skip_vote: skip_vote_status(theatre) } + json[:skipped] = skipped unless skipped.nil? + json + end + + def skip_vote_status(theatre) + watching_user_ids = theatre.watching_users.ids + watching_users_count = watching_user_ids.size + required_count = (watching_users_count / 2) + 1 + post = theatre.current_post + votes = + if post + TheatreSkipVote.where(theatre:, post:, user_id: watching_user_ids) + else + TheatreSkipVote.none + end + + { votes_count: post ? votes.count : 0, + required_count:, + watching_users_count:, + voted: post && current_user ? votes.exists?(user_id: current_user.id) : false } + end end diff --git a/backend/app/models/theatre.rb b/backend/app/models/theatre.rb index 3fbdc6a..7912fe3 100644 --- a/backend/app/models/theatre.rb +++ b/backend/app/models/theatre.rb @@ -8,6 +8,8 @@ class Theatre < ApplicationRecord has_many :watching_users, through: :active_theatre_watching_users, source: :user has_many :programmes, class_name: 'TheatreProgramme' + has_many :skip_votes, class_name: 'TheatreSkipVote', dependent: :delete_all + has_many :skip_events, class_name: 'TheatreSkipEvent', dependent: :delete_all belongs_to :host_user, class_name: 'User', optional: true belongs_to :current_post, class_name: 'Post', optional: true diff --git a/backend/app/models/theatre_skip_event.rb b/backend/app/models/theatre_skip_event.rb new file mode 100644 index 0000000..80936ed --- /dev/null +++ b/backend/app/models/theatre_skip_event.rb @@ -0,0 +1,10 @@ +class TheatreSkipEvent < ApplicationRecord + belongs_to :theatre + belongs_to :post + belongs_to :skipped_by_user, class_name: 'User' + + has_many :voters, class_name: 'TheatreSkipEventVoter', dependent: :delete_all + has_many :event_tags, class_name: 'TheatreSkipEventTag', dependent: :delete_all + has_many :users, through: :voters + has_many :tags, through: :event_tags +end diff --git a/backend/app/models/theatre_skip_event_tag.rb b/backend/app/models/theatre_skip_event_tag.rb new file mode 100644 index 0000000..e9ce248 --- /dev/null +++ b/backend/app/models/theatre_skip_event_tag.rb @@ -0,0 +1,6 @@ +class TheatreSkipEventTag < ApplicationRecord + self.primary_key = :theatre_skip_event_id, :tag_id + + belongs_to :theatre_skip_event + belongs_to :tag +end diff --git a/backend/app/models/theatre_skip_event_voter.rb b/backend/app/models/theatre_skip_event_voter.rb new file mode 100644 index 0000000..3db5b71 --- /dev/null +++ b/backend/app/models/theatre_skip_event_voter.rb @@ -0,0 +1,6 @@ +class TheatreSkipEventVoter < ApplicationRecord + self.primary_key = :theatre_skip_event_id, :user_id + + belongs_to :theatre_skip_event + belongs_to :user +end diff --git a/backend/app/models/theatre_skip_vote.rb b/backend/app/models/theatre_skip_vote.rb new file mode 100644 index 0000000..ecba421 --- /dev/null +++ b/backend/app/models/theatre_skip_vote.rb @@ -0,0 +1,7 @@ +class TheatreSkipVote < ApplicationRecord + self.primary_key = :theatre_id, :post_id, :user_id + + belongs_to :theatre + belongs_to :post + belongs_to :user +end diff --git a/backend/app/services/theatre_post_advancer.rb b/backend/app/services/theatre_post_advancer.rb new file mode 100644 index 0000000..c7d9e6c --- /dev/null +++ b/backend/app/services/theatre_post_advancer.rb @@ -0,0 +1,29 @@ +class TheatrePostAdvancer + def self.call(theatre:) + new(theatre:).call + end + + def initialize(theatre:) + @theatre = theatre + end + + def call + previous_post = theatre.current_post + post = TheatrePostSelector.new(theatre:).select + + TheatreSkipVote.where(theatre:, post: previous_post).delete_all if previous_post + + theatre.update!(current_post: post, current_post_started_at: post ? Time.current : nil) + + if post + position = (theatre.programmes.maximum(:position) || 0) + 1 + theatre.programmes.create!(position:, post:) + end + + post + end + + private + + attr_reader :theatre +end diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb new file mode 100644 index 0000000..bbff14b --- /dev/null +++ b/backend/app/services/theatre_post_selector.rb @@ -0,0 +1,92 @@ +class TheatrePostSelector + Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true) + + def initialize(theatre:) + @theatre = theatre + end + + def select + candidates = weighted_candidates + return nil if candidates.empty? + + total = candidates.sum(&:weight) + target = rand * total + + candidates.each do |candidate| + target -= candidate.weight + return candidate.post if target <= 0 + end + + candidates.last.post + end + + def weight_json(limit: 20) + candidates = weighted_candidates + sorted = candidates.sort_by { |candidate| [candidate.weight, candidate.post.id] } + + { tag_penalties: tag_penalty_json, + lightest_posts: post_weight_json(sorted.first(limit)), + heaviest_posts: post_weight_json(sorted.reverse.first(limit)) } + end + + private + + attr_reader :theatre + + def weighted_candidates + @weighted_candidates ||= begin + penalties = tag_penalties + posts = eligible_posts.includes(tags: :tag_name).to_a + + 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)) + end + end + end + + def eligible_posts + posts = Post.where("url LIKE '%nicovideo.jp%'") + posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id + posts + end + + def active_user_ids + @active_user_ids ||= theatre.watching_users.ids + end + + def tag_penalties + @tag_penalties ||= + 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 + + 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] } + end + + def post_weight_json(candidates) + candidates.map { |candidate| + { post: PostRepr.base(candidate.post), + weight: candidate.weight, + penalty: candidate.penalty, + tags: candidate.tags.map { |tag| TagRepr.inline(tag) } } + } + end +end diff --git a/backend/app/services/theatre_skip_finalizer.rb b/backend/app/services/theatre_skip_finalizer.rb new file mode 100644 index 0000000..b098df1 --- /dev/null +++ b/backend/app/services/theatre_skip_finalizer.rb @@ -0,0 +1,40 @@ +class TheatreSkipFinalizer + def self.call(theatre:, user:) + new(theatre:, user:).call + end + + def initialize(theatre:, user:) + @theatre = theatre + @user = user + end + + def call + return unless theatre.current_post + + post = theatre.current_post + voters = TheatreSkipVote.where(theatre:, post:).includes(:user).map(&:user) + return if voters.empty? + + event = TheatreSkipEvent.create!( + theatre:, + post:, + skipped_by_user: user, + programme_position: theatre.programmes.maximum(:position)) + + voters.uniq(&:id).each do |voter| + TheatreSkipEventVoter.create!(theatre_skip_event: event, user: voter) + end + + post.tags.find_each do |tag| + TheatreSkipEventTag.create!(theatre_skip_event: event, tag:) + end + + TheatreSkipVote.where(theatre:, post:).delete_all + + event + end + + private + + attr_reader :theatre, :user +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index dfe2f92..ab9fdc3 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -85,10 +85,14 @@ Rails.application.routes.draw do member do put :watching patch :next_post + put :skip_vote + delete :skip_vote, action: :unskip_vote + get :post_selection_weights end resources :comments, controller: :theatre_comments, only: [:index, :create, :destroy] resources :programmes, controller: :theatre_programmes, only: [:index] + resources :skip_events, controller: :theatre_skip_events, only: [:index] end resources :materials, only: [:index, :show, :create, :update, :destroy] diff --git a/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb new file mode 100644 index 0000000..8361d6a --- /dev/null +++ b/backend/db/migrate/20260606000000_create_theatre_skip_votes_and_events.rb @@ -0,0 +1,36 @@ +class CreateTheatreSkipVotesAndEvents < ActiveRecord::Migration[8.0] + def change + create_table :theatre_skip_votes, primary_key: [:theatre_id, :post_id, :user_id] do |t| + t.references :theatre, null: false, foreign_key: true + t.references :post, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + t.timestamps + end + + create_table :theatre_skip_events do |t| + t.references :theatre, null: false, foreign_key: true + t.references :post, null: false, foreign_key: true + t.references :skipped_by_user, null: false, foreign_key: { to_table: :users } + t.integer :programme_position + t.datetime :created_at, null: false + end + + create_table :theatre_skip_event_voters, primary_key: [:theatre_skip_event_id, :user_id] do |t| + t.references :theatre_skip_event, null: false, foreign_key: true + t.references :user, null: false, foreign_key: true + end + + create_table :theatre_skip_event_tags, primary_key: [:theatre_skip_event_id, :tag_id] do |t| + t.references :theatre_skip_event, null: false, foreign_key: true + t.references :tag, null: false, foreign_key: true + end + + add_index :theatre_skip_events, [:theatre_id, :created_at] + add_index :theatre_skip_votes, [:theatre_id, :post_id, :created_at], + name: 'idx_theatre_skip_votes_theatre_post_created' + add_index :theatre_skip_event_voters, [:user_id, :theatre_skip_event_id], + name: 'idx_theatre_skip_event_voters_user_event' + add_index :theatre_skip_event_tags, [:tag_id, :theatre_skip_event_id], + name: 'idx_theatre_skip_event_tags_tag_event' + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b5e2118..ad3325b 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do +ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -137,6 +137,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end + create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "post_id", null: false + t.bigint "tag_id", null: false + t.integer "begin_ms", null: false + t.integer "end_ms", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms" + t.index ["tag_id"], name: "fk_rails_8be3847903" + t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms" + t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" + end + create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "tag_id", null: false @@ -187,8 +200,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do t.datetime "original_created_before" t.datetime "updated_at", null: false t.integer "version_no", null: false + t.integer "video_ms" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true + t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" + t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end @@ -292,6 +308,46 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do t.index ["theatre_id"], name: "index_theatre_programmes_on_theatre_id" end + create_table "theatre_skip_event_tags", primary_key: ["theatre_skip_event_id", "tag_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "theatre_skip_event_id", null: false + t.bigint "tag_id", null: false + t.index ["tag_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_tags_tag_event" + t.index ["tag_id"], name: "index_theatre_skip_event_tags_on_tag_id" + t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_tags_on_theatre_skip_event_id" + end + + create_table "theatre_skip_event_voters", primary_key: ["theatre_skip_event_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "theatre_skip_event_id", null: false + t.bigint "user_id", null: false + t.index ["theatre_skip_event_id"], name: "index_theatre_skip_event_voters_on_theatre_skip_event_id" + t.index ["user_id", "theatre_skip_event_id"], name: "idx_theatre_skip_event_voters_user_event" + t.index ["user_id"], name: "index_theatre_skip_event_voters_on_user_id" + end + + create_table "theatre_skip_events", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "theatre_id", null: false + t.bigint "post_id", null: false + t.bigint "skipped_by_user_id", null: false + t.integer "programme_position" + t.datetime "created_at", null: false + t.index ["post_id"], name: "index_theatre_skip_events_on_post_id" + t.index ["skipped_by_user_id"], name: "index_theatre_skip_events_on_skipped_by_user_id" + t.index ["theatre_id", "created_at"], name: "index_theatre_skip_events_on_theatre_id_and_created_at" + t.index ["theatre_id"], name: "index_theatre_skip_events_on_theatre_id" + end + + create_table "theatre_skip_votes", primary_key: ["theatre_id", "post_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.bigint "theatre_id", null: false + t.bigint "post_id", null: false + t.bigint "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["post_id"], name: "index_theatre_skip_votes_on_post_id" + t.index ["theatre_id", "post_id", "created_at"], name: "idx_theatre_skip_votes_theatre_post_created" + t.index ["theatre_id"], name: "index_theatre_skip_votes_on_theatre_id" + t.index ["user_id"], name: "index_theatre_skip_votes_on_user_id" + end + create_table "theatre_watching_users", primary_key: ["theatre_id", "user_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "theatre_id", null: false t.bigint "user_id", null: false @@ -456,6 +512,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" + add_foreign_key "post_tag_sections", "posts" + add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" @@ -476,6 +534,16 @@ ActiveRecord::Schema[8.0].define(version: 2026_05_14_221900) do add_foreign_key "theatre_comments", "users" add_foreign_key "theatre_programmes", "posts" add_foreign_key "theatre_programmes", "theatres" + add_foreign_key "theatre_skip_event_tags", "tags" + add_foreign_key "theatre_skip_event_tags", "theatre_skip_events" + add_foreign_key "theatre_skip_event_voters", "theatre_skip_events" + add_foreign_key "theatre_skip_event_voters", "users" + add_foreign_key "theatre_skip_events", "posts" + add_foreign_key "theatre_skip_events", "theatres" + add_foreign_key "theatre_skip_events", "users", column: "skipped_by_user_id" + add_foreign_key "theatre_skip_votes", "posts" + add_foreign_key "theatre_skip_votes", "theatres" + add_foreign_key "theatre_skip_votes", "users" add_foreign_key "theatre_watching_users", "theatres" add_foreign_key "theatre_watching_users", "users" add_foreign_key "theatres", "posts", column: "current_post_id" diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb new file mode 100644 index 0000000..529b583 --- /dev/null +++ b/backend/spec/requests/theatre_programmes_spec.rb @@ -0,0 +1,31 @@ +require 'rails_helper' + +RSpec.describe 'TheatreProgrammes', type: :request do + describe 'GET /theatres/:theatre_id/programmes' do + let(:theatre) { create(:theatre) } + let(:other_theatre) { create(:theatre) } + let(:post_1) { Post.create!(title: 'first', url: 'https://www.nicovideo.jp/watch/sm1') } + let(:post_2) { Post.create!(title: 'second', url: 'https://www.nicovideo.jp/watch/sm2') } + let(:other_post) { Post.create!(title: 'other', url: 'https://www.nicovideo.jp/watch/sm3') } + + before do + TheatreProgramme.create!(theatre:, position: 1, post: post_1, created_at: 2.minutes.ago) + TheatreProgramme.create!(theatre:, position: 2, post: post_2, created_at: 1.minute.ago) + TheatreProgramme.create!( + theatre: other_theatre, + position: 1, + post: other_post, + created_at: 1.minute.ago + ) + end + + it 'returns programmes for the theatre in descending position with post json' do + get "/theatres/#{theatre.id}/programmes" + + expect(response).to have_http_status(:ok) + expect(json.map { _1['position'] }).to eq([2, 1]) + expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first']) + expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url) + end + end +end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 45a4b85..16d0de7 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -14,10 +14,17 @@ RSpec.describe 'Theatres API', type: :request do let(:member) { create(:user, :member, name: 'member user') } let(:other_user) { create(:user, :member, name: 'other user') } - let!(:youtube_post) do + let!(:niconico_post) do Post.create!( - title: 'youtube post', - url: 'https://www.youtube.com/watch?v=spec123' + title: 'niconico post', + url: 'https://www.nicovideo.jp/watch/sm123' + ) + end + + let!(:second_niconico_post) do + Post.create!( + title: 'second niconico post', + url: 'https://www.nicovideo.jp/watch/sm456' ) end @@ -120,7 +127,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => true, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -177,7 +185,8 @@ RSpec.describe 'Theatres API', type: :request do expect(json).to include( 'host_flg' => false, 'post_id' => nil, - 'post_started_at' => nil + 'post_started_at' => nil, + 'post_elapsed_ms' => nil ) expect(json.fetch('watching_users')).to contain_exactly( @@ -204,7 +213,7 @@ RSpec.describe 'Theatres API', type: :request do ) theatre.update!( host_user: other_user, - current_post: youtube_post, + current_post: niconico_post, current_post_started_at: started_at ) sign_in_as(member) @@ -220,9 +229,11 @@ RSpec.describe 'Theatres API', type: :request do expect(theatre.host_user_id).to eq(member.id) expect(json['host_flg']).to eq(true) - expect(json['post_id']).to eq(youtube_post.id) + expect(json['post_id']).to eq(niconico_post.id) expect(Time.zone.parse(json['post_started_at'])) .to be_within(1.second).of(started_at) + expect(json['post_elapsed_ms']) + .to be_within(1_000).of(120_000) end end end @@ -273,17 +284,20 @@ RSpec.describe 'Theatres API', type: :request do it 'sets current_post to an eligible post and updates current_post_started_at' do expect { do_request } .to change { theatre.reload.current_post_id } - .from(nil).to(youtube_post.id) expect(response).to have_http_status(:no_content) + expect([niconico_post.id, second_niconico_post.id]) + .to include(theatre.reload.current_post_id) expect(theatre.reload.current_post_started_at) .to be_within(1.second).of(Time.current) + expect(theatre.programmes.count).to eq(1) end end context 'when current user is host and no eligible post exists' do before do - youtube_post.destroy! + niconico_post.destroy! + second_niconico_post.destroy! theatre.update!( host_user: member, current_post: other_post, @@ -299,9 +313,117 @@ RSpec.describe 'Theatres API', type: :request do theatre.reload expect(theatre.current_post_id).to be_nil - expect(theatre.current_post_started_at) - .to be_within(1.second).of(Time.current) + expect(theatre.current_post_started_at).to be_nil end end end + + describe 'PUT /theatres/:id/skip_vote' do + subject(:do_request) do + put "/theatres/#{theatre.id}/skip_vote" + end + + let(:third_user) { create(:user, :member, name: 'third user') } + + before do + theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) + [member, other_user, third_user].each do |user| + TheatreWatchingUser.create!( + theatre:, + user:, + expires_at: 10.seconds.from_now + ) + end + end + + it 'records a vote and returns the current vote status before majority' do + sign_in_as(member) + + expect { do_request }.to change(TheatreSkipVote, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json['skipped']).to eq(false) + expect(json['post_id']).to eq(niconico_post.id) + expect(json['skip_vote']).to include( + 'votes_count' => 1, + 'required_count' => 2, + 'watching_users_count' => 3, + 'voted' => true + ) + end + + it 'finalizes skip when votes reach majority and stores voters and tag snapshots' do + tag = create(:tag, name: 'skip-target') + PostTag.create!(post: niconico_post, tag:) + + TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) + sign_in_as(other_user) + + expect { do_request } + .to change(TheatreSkipEvent, :count).by(1) + .and change(TheatreSkipEventVoter, :count).by(2) + .and change(TheatreSkipEventTag, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json['skipped']).to eq(true) + expect(json['post_id']).to eq(second_niconico_post.id) + + event = TheatreSkipEvent.last + expect(event.post).to eq(niconico_post) + expect(event.users).to contain_exactly(member, other_user) + expect(event.tags).to contain_exactly(tag) + expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty + end + end + + describe 'DELETE /theatres/:id/skip_vote' do + before do + theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) + TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now) + TheatreSkipVote.create!(theatre:, post: niconico_post, user: member) + sign_in_as(member) + end + + it 'removes the current user vote' do + expect { + delete "/theatres/#{theatre.id}/skip_vote" + }.to change(TheatreSkipVote, :count).by(-1) + + expect(response).to have_http_status(:ok) + expect(json['skip_vote']).to include( + 'votes_count' => 0, + 'required_count' => 1, + 'watching_users_count' => 1, + 'voted' => false + ) + end + end + + describe 'GET /theatres/:id/post_selection_weights' do + before do + theatre.update!(current_post: niconico_post) + TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now) + sign_in_as(member) + end + + it 'returns tag penalties and candidate weights for the current watchers' do + tag = create(:tag, name: 'heavy-tag') + PostTag.create!(post: second_niconico_post, tag:) + event = TheatreSkipEvent.create!( + theatre:, + post: niconico_post, + skipped_by_user: member, + created_at: Time.current + ) + TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member) + TheatreSkipEventTag.create!(theatre_skip_event: event, tag:) + + get "/theatres/#{theatre.id}/post_selection_weights" + + expect(response).to have_http_status(:ok) + expect(json['tag_penalties'].first['penalty']).to eq(1) + expect(json['lightest_posts'].first['post']['id']).to eq(second_niconico_post.id) + expect(json['lightest_posts'].first['penalty']).to eq(1) + end + end end diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index a1d0390..ed65823 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -37,11 +37,12 @@ type Props = { height: number style?: CSSProperties onLoadComplete?: (info: NiconicoVideoInfo) => void - onMetadataChange?: (meta: NiconicoMetadata) => void } + onMetadataChange?: (meta: NiconicoMetadata) => void + onError?: (data: unknown) => void } export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle>) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props const iframeRef = useRef<HTMLIFrameElement> (null) const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) @@ -173,13 +174,16 @@ export default forwardRef ((props: Props, ref: ForwardedRef<NiconicoViewerHandle } if (data.eventName === 'error') - console.error ('niconico player error:', data) + { + console.error ('niconico player error:', data) + onError?.(data) + } } addEventListener ('message', onMessage) return () => removeEventListener ('message', onMessage) - }, [onLoadComplete, onMetadataChange, playerId]) + }, [onError, onLoadComplete, onMetadataChange, playerId]) useLayoutEffect (() => { if (!(fullScreen)) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 8ae3220..6c04f48 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -13,10 +13,11 @@ type Props = { ref?: RefObject<NiconicoViewerHandle | null> post: Post onLoadComplete?: (info: NiconicoVideoInfo) => void - onMetadataChange?: (meta: NiconicoMetadata) => void } + onMetadataChange?: (meta: NiconicoMetadata) => void + onError?: (data: unknown) => void } -const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) => { +const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => { const dialogue = useDialogue () const [framed, setFramed] = useState (false) @@ -39,7 +40,8 @@ const PostEmbed: FC<Props> = ({ ref, post, onLoadComplete, onMetadataChange }) = width={640} height={360} onLoadComplete={onLoadComplete} - onMetadataChange={onMetadataChange}/>) + onMetadataChange={onMetadataChange} + onError={onError}/>) } case 'twitter.com': diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 12235fe..4a7e20b 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -128,8 +128,12 @@ const TopNav: FC<Props> = ({ user }) => { const menu = menuOutline ({ tag, wikiId, user, pathName: location.pathname }) const visibleMenu = menu.filter ((item): item is MenuVisibleItem => item.visible ?? true) + const moreMenu = menu.filter (item => + !(item.visible ?? true) + || item.subMenu.filter (subItem => subItem.visible ?? true).length > 0) const activeIdx = visibleMenu.findIndex (item => location.pathname.startsWith (item.base || item.to)) + const submenuHeight = moreVsbl ? 40 * moreMenu.length : (activeIdx < 0 ? 0 : 40) const prevActiveIdxRef = useRef<number> (activeIdx) @@ -240,9 +244,9 @@ const TopNav: FC<Props> = ({ user }) => { <motion.div key="submenu-shell" layout - className="relative hidden md:block overflow-hidden + className="relative z-20 hidden md:block overflow-hidden bg-yellow-200 dark:bg-red-950" - style={{ height: moreVsbl ? 40 * menu.length : (activeIdx < 0 ? 0 : 40) }} + animate={{ height: submenuHeight }} onMouseLeave={() => { if (moreVsbl) setMoreVsbl (false) @@ -253,7 +257,7 @@ const TopNav: FC<Props> = ({ user }) => { }}> {moreVsbl ? ( - menu.map ((item, i) => ( + moreMenu.map ((item, i) => ( <div key={i} className="relative h-[40px]"> <div className="absolute inset-0 flex items-center px-3"> <motion.div diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 03ba651..a93de11 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -64,11 +64,14 @@ export const apiPatch = async <T> ( ): Promise<T> => apiP ('patch', path, body, opt) -export const apiDelete = async ( +export const apiDelete = async <T = void> ( path: string, opt?: Opt, -): Promise<void> => { - await client.delete (path, withUserCode (opt)) +): Promise<T> => { + const res = await client.delete (path, withUserCode (opt)) + if (res.data == null || res.data === '') + return undefined as T + return toCamel (res.data as Record<string, unknown>, { deep: true }) as T } diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index d356cb6..bf68703 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -1,96 +1,267 @@ -import { useEffect, useRef, useState } from 'react' +import { motion } from 'framer-motion' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import ErrorScreen from '@/components/ErrorScreen' +import PostEditForm from '@/components/PostEditForm' import PostEmbed from '@/components/PostEmbed' import PrefetchLink from '@/components/PrefetchLink' -import TagDetailSidebar from '@/components/TagDetailSidebar' +import TagLink from '@/components/TagLink' import FieldError from '@/components/common/FieldError' -import SectionTitle from '@/components/common/SectionTitle' import { useDialogue } from '@/components/dialogues/DialogueProvider' -import MainArea from '@/components/layout/MainArea' -import SidebarComponent from '@/components/layout/SidebarComponent' +import { Button } from '@/components/ui/button' import { SITE_TITLE } from '@/config' -import { apiGet, apiDelete, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' +import { CATEGORIES, CATEGORY_NAMES } from '@/consts' +import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api' import { fetchPost } from '@/lib/posts' -import { dateString, inputClass } from '@/lib/utils' +import { cn, dateString, inputClass } from '@/lib/utils' import { useValidationErrors } from '@/lib/useValidationErrors' -import type { FC, ReactNode, User } from 'react' +import type { FC, FormEvent, ReactNode } from 'react' import type { NiconicoMetadata, NiconicoViewerHandle, Post, + Category, + Tag, Theatre, TheatreComment, - TheatreProgramme } from '@/types' - -type TheatreInfo = { - hostFlg: boolean - postId: number | null - postStartedAt: string | null - watchingUsers: { id: number; name: string }[] } + TheatreInfo, + TheatrePostSelectionWeights, + TheatreProgramme, + User } from '@/types' type TheatreCommentField = 'content' +type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom' +type TagFlow = 'vertical' | 'horizontal' -const INITIAL_THEATRE_INFO = +const INITIAL_THEATRE_INFO: TheatreInfo = { hostFlg: false, postId: null, postStartedAt: null, - watchingUsers: [] as { id: number; name: string }[] } as const + postElapsedMs: null, + watchingUsers: [], + skipVote: { + votesCount: 0, + requiredCount: 1, + watchingUsersCount: 0, + voted: false } } + +const INITIAL_WEIGHTS: TheatrePostSelectionWeights = + { tagPenalties: [], lightestPosts: [], heaviestPosts: [] } + +const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' +const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' + +const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = { + threeColumns: '3 コラム', + tagsBottom: 'タグ下', + commentsBottom: 'コメント下' } + +const TAG_FLOW_LABELS: Record<TagFlow, string> = { + vertical: 'タグ縦', + horizontal: 'タグ横' } -const commentBox: ReactNode[] = (comment: TheatreComment) => +const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string => + user ? (user.name || `名もなきニジラー(#${ user.id })`) : '運営' + + +const commentBox = ( + comment: TheatreComment, + programme: TheatreProgramme | null = null, +): ReactNode[] => [( <div key={`${ comment.no }-content`} className="w-full"> {comment.deleted - ? ( - <span className="text-sm font-bold"> - 削除されました. - </span>) + ? <span className="text-sm font-bold">削除されました.</span> : comment.content} </div>), ( <div key={`${ comment.no }-user`} className="w-full text-sm text-right"> - by {comment.user - ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) - : '運営'} + by {userName (comment.user)} </div>), ( <div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right"> {dateString (comment.createdAt)} + </div>), + ( + <div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400"> + {programme ? ( + <> + この時の動画: + <PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline"> + {programme.post.title || programme.post.url} + </PrefetchLink> + </>) : 'この時の動画:履歴外'} </div>)] -type Props = { user: User } +const compareTagName = (a: Tag, b: Tag): number => + a.name === b.name ? 0 : (a.name < b.name ? -1 : 1) + + +const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => { + const grouped: Partial<Record<Category, Tag[]>> = { } + + for (const tag of tags) + { + grouped[tag.category] ??= [] + grouped[tag.category]!.push (tag) + } + + for (const cat of CATEGORIES) + grouped[cat]?.sort (compareTagName) + + return grouped +} + + +const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = +({ tags, compact, flow = 'vertical' }) => { + const grouped = tagsByCategory (tags) + + if (flow === 'horizontal') + return ( + <ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}> + {CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => ( + <li key={tag.id} className="text-left leading-tight"> + <TagLink tag={tag} withCount={false}/> + </li>))} + </ul>) + + return ( + <div className="space-y-3"> + {CATEGORIES.map (cat => { + const rows = grouped[cat] ?? [] + if (rows.length === 0) + return null + + return ( + <div key={cat}> + <div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400"> + {CATEGORY_NAMES[cat]} + </div> + <ul + className={cn ( + 'space-y-1', + compact && 'text-sm')}> + {rows.map (tag => ( + <li key={tag.id} className="text-left leading-tight"> + <TagLink tag={tag} withCount={false}/> + </li>))} + </ul> + </div>) + })} + </div>) +} + + +type Props = { user: User | null } const TheatreDetailPage: FC<Props> = ({ user }: Props) => { const { id } = useParams () - const dialogue = useDialogue () const commentsRef = useRef<HTMLDivElement> (null) const embedRef = useRef<NiconicoViewerHandle> (null) const loadingRef = useRef (false) const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) + const theatreInfoReceivedAtRef = useRef (performance.now ()) const videoLengthRef = useRef (0) const lastCommentNoRef = useRef (0) const [comments, setComments] = useState<TheatreComment[]> ([]) const [content, setContent] = useState ('') + const [editingPost, setEditingPost] = useState<Post | null> (null) const [loading, setLoading] = useState (false) + const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([]) const [sending, setSending] = useState (false) const [status, setStatus] = useState (200) const [theatre, setTheatre] = useState<Theatre | null> (null) const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) const [post, setPost] = useState<Post | null> (null) - const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([]) const [videoLength, setVideoLength] = useState (0) + const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS) + const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => { + const stored = localStorage.getItem (LAYOUT_STORAGE_KEY) + return (['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[]) + .includes (stored as TheatreLayoutMode) + ? stored as TheatreLayoutMode + : 'threeColumns' + }) + const [tagFlow, setTagFlow] = useState<TagFlow> (() => { + const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY) + return (['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow) + ? stored as TagFlow + : 'vertical' + }) const { fieldErrors, clearValidationErrors, applyValidationError } = useValidationErrors<TheatreCommentField> () + const changeLayoutMode = (mode: TheatreLayoutMode) => { + setLayoutMode (mode) + localStorage.setItem (LAYOUT_STORAGE_KEY, mode) + } + + const changeTagFlow = (flow: TagFlow) => { + setTagFlow (flow) + localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow) + } + + const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => { + theatreInfoReceivedAtRef.current = performance.now () + setTheatreInfo (nextInfo) + }, []) + + const currentPostElapsedMs = useCallback ((info: TheatreInfo = theatreInfoRef.current): number => { + if (info.postElapsedMs == null) + return 0 + + return Math.max ( + info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current, + 0) + }, []) + + const refreshProgrammes = useCallback (async () => { + if (!(id)) + return + + setProgrammes (await apiGet<TheatreProgramme[]> ( + `/theatres/${ id }/programmes`, { params: { limit: '100' } })) + }, [id]) + + const refreshWeights = useCallback (async () => { + if (!(id)) + return + + setWeights (await apiGet<TheatrePostSelectionWeights> ( + `/theatres/${ id }/post_selection_weights`)) + }, [id]) + + const advancePost = useCallback (async () => { + if (!(id)) + return + + setLoading (true) + try + { + await apiPatch<void> (`/theatres/${ id }/next_post`) + await refreshProgrammes () + await refreshWeights () + } + catch (error) + { + console.error (error) + } + finally + { + setLoading (false) + } + }, [id, refreshProgrammes, refreshWeights]) + useEffect (() => { loadingRef.current = loading }, [loading]) @@ -114,10 +285,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { let cancelled = false setComments ([]) - setTheatre (null) + setEditingPost (null) setPost (null) + setProgrammes ([]) + setTheatre (null) + theatreInfoReceivedAtRef.current = performance.now () setTheatreInfo (INITIAL_THEATRE_INFO) setVideoLength (0) + setWeights (INITIAL_WEIGHTS) lastCommentNoRef.current = 0 void (async () => { @@ -133,10 +308,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { } }) () + void refreshProgrammes () + void refreshWeights () + return () => { cancelled = true } - }, [id]) + }, [id, refreshProgrammes, refreshWeights]) useEffect (() => { if (!(id)) @@ -167,20 +345,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { const ended = currentInfo.hostFlg && currentInfo.postStartedAt - && ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ()) - > videoLengthRef.current + 3_000) + && videoLengthRef.current > 0 + && currentPostElapsedMs (currentInfo) > videoLengthRef.current + 3_000 if (ended) { if (!(cancelled)) - setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null })) + setTheatreInfo (prev => ({ + ...prev, + postId: null, + postStartedAt: null, + postElapsedMs: null })) return } const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`) if (!(cancelled)) - setTheatreInfo (nextInfo) + applyTheatreInfo (nextInfo) } catch (error) { @@ -199,7 +381,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { cancelled = true clearInterval (interval) } - }, [id]) + }, [applyTheatreInfo, currentPostElapsedMs, id]) useEffect (() => { if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null) @@ -208,33 +390,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { let cancelled = false void (async () => { - setLoading (true) - - try - { - await apiPatch<void> (`/theatres/${ id }/next_post`) - } - catch (error) - { - console.error (error) - } - finally - { - if (!(cancelled)) - setLoading (false) - } + await advancePost () + if (cancelled) + setLoading (false) }) () return () => { cancelled = true } - }, [id, theatreInfo.hostFlg, theatreInfo.postId]) + }, [advancePost, id, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { setVideoLength (0) if (theatreInfo.postId == null) - return + { + setPost (null) + return + } let cancelled = false @@ -251,31 +424,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { } }) () - void (async () => { - try - { - const data = await apiGet<TheatreProgramme[]> ( - `/theatres/${ id }/programmes`, { params: { limit: '100' } }) - if (!(cancelled)) - setProgrammes (data) - } - catch (e) - { - console.error (e) - } - }) () - return () => { cancelled = true } - }, [id, theatreInfo.postId]) + }, [theatreInfo.postId]) + + useEffect (() => { + void refreshProgrammes () + }, [refreshProgrammes, theatreInfo.postId]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) return const targetTime = Math.min ( - Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), + currentPostElapsedMs (theatreInfo), videoLength) const drift = Math.abs (meta.currentTime - targetTime) @@ -284,6 +447,44 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { embedRef.current?.seek (targetTime) } + const handlePlaybackError = async () => { + if (!(theatreInfoRef.current.hostFlg) || loadingRef.current) + return + + await advancePost () + } + + const handleSkipVote = async () => { + if (!(id) || !(post)) + return + + setLoading (true) + try + { + const nextInfo = + theatreInfo.skipVote.voted + ? await apiDelete<TheatreInfo> (`/theatres/${ id }/skip_vote`) + : await apiPut<TheatreInfo> (`/theatres/${ id }/skip_vote`) + + applyTheatreInfo (nextInfo) + + if (nextInfo.skipped) + { + setPost (null) + await refreshProgrammes () + await refreshWeights () + } + } + catch (error) + { + console.error (error) + } + finally + { + setLoading (false) + } + } + const handleDelete = async (commentNo: number) => { try { @@ -291,7 +492,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { setComments (prev => { const rtn = [...prev] const idx = rtn.findIndex (x => x.no === commentNo) - rtn[idx] = { ...rtn[idx], deleted: true } + if (idx >= 0) + rtn[idx] = { ...rtn[idx], deleted: true, content: null } return rtn }) } @@ -301,158 +503,390 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => { } } + const handleCommentSubmit = async (e: FormEvent) => { + e.preventDefault () + + if (!(content)) + return + + try + { + setSending (true) + clearValidationErrors () + await apiPost (`/theatres/${ id }/comments`, { content }) + setContent ('') + commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) + } + catch (error) + { + applyValidationError (error) + } + finally + { + setSending (false) + } + } + + const skipVote = theatreInfo.skipVote + const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場' + const postTags = post?.tags ?? [] + const programmesAsc = useMemo ( + () => [...programmes].sort ( + (a, b) => Date.parse (a.createdAt) - Date.parse (b.createdAt)), + [programmes]) + + const programmeForComment = useCallback ((comment: TheatreComment): TheatreProgramme | null => { + const commentedAt = Date.parse (comment.createdAt) + let found: TheatreProgramme | null = null + + for (const programme of programmesAsc) + { + const startedAt = Date.parse (programme.createdAt) + if (startedAt > commentedAt) + break + + found = programme + } + + return found + }, [programmesAsc]) + if (status >= 400) return <ErrorScreen status={status}/> - return ( - <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> - <Helmet> - <meta name="robots" content="noindex"/> - {theatre && ( - <title> - {'上映会場' - + (theatre.name ? `『${ theatre.name }』` : ` #${ theatre.id }`) - + ` | ${ SITE_TITLE }`} - )} - + const tagPanel = ( +
+
+

タグ

+ {layoutMode === 'tagsBottom' && ( +
+ {(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => ( + ))} +
)} +
+ {postTags.length === 0 + ?
タグはありません。
+ : } +
) -
- {post && } + const commentsPanel = ( +
+

コメント

+
+ 0)} + type="text" + placeholder="ここにコメントを入力" + value={content} + onChange={e => setContent (e.target.value)} + disabled={sending}/> + + + +
+ {comments.map (comment => { + const commentProgramme = programmeForComment (comment) + + return ( +
+ {(user && comment.user?.id === user.id && !(comment.deleted)) && ( + )} + {commentBox (comment, commentProgramme)} +
) + })} +
+
) + + const participantsPanel = ( +
+

参加者

+
+ {theatreInfo.watchingUsers.map (watchingUser => ( +
+ {userName (watchingUser)} + {watchingUser.id === user?.id && 自分} +
))} +
+
) + + const historyPanel = ( +
+

再生履歴

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

今の抽選重み

+
- - {post ? ( - <> - { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} - onMetadataChange={syncPlayback}/> -
- <>再生中: - - {post.title || post.url} - -
- ) : 'Loading...'} +
+
+

下がってゐるタグ

+
+ {weights.tagPenalties.length === 0 ? ( +
まだ減点はありません。
) : weights.tagPenalties.slice (0, 12).map (row => ( +
+
+ +
+ {row.penalty} +
))} +
+
- - 上映履歴 - - -
- {programmes.map ((programme, i) => ( -
- - {programme.post.title} - - ({dateString (programme.createdAt)}) -
))} -
+

出にくい候補

+
- - -
{ - e.preventDefault () - - if (!(content)) - return - - try - { - setSending (true) - clearValidationErrors () - await apiPost (`/theatres/${ id }/comments`, { content }) - setContent ('') - commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) - } - catch (error) - { - applyValidationError (error) - } - finally - { - setSending (false) - } - }}> - 0)} - type="text" - placeholder="ここにコメントを入力" - value={content} - onChange={e => setContent (e.target.value)} - disabled={sending}/> - - -
- {comments.map (comment => ( -
- {(user && comment.user?.id === user.id && !(comment.deleted)) && ( - )} - {commentBox (comment)} -
))} -
- - -
-
- 現在の同接数:{theatreInfo.watchingUsers.length} -
- -
-
    - {theatreInfo.watchingUsers.map (user => ( -
  • - {user.name || `名もなきニジラー(#${ user.id })`} -
  • ))} -
-
+
+

出やすい候補

+
- - -
- {post && }
-
) +
) + + return ( + + + + {theatre && {`${ theatreTitle } | ${ SITE_TITLE }`}} + + +
+ {layoutMode !== 'tagsBottom' && ( + +
+ {tagPanel} +
+
)} + + +
+
+
+
+

{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} + ) : ( + 未選択)} +
+ + +
+
+ + {editingPost && ( +
+
+
+

編集中の投稿

+

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

+
+ + +
+ + { + setEditingPost (newPost) + if (post?.id === newPost.id) + setPost (newPost) + void refreshWeights () + }}/> +
)} + +
+ {commentsPanel} +
+ + {layoutMode === 'commentsBottom' && ( +
+ {commentsPanel} +
)} + +
+ {tagPanel} +
+ + {layoutMode === 'tagsBottom' && ( +
+ {tagPanel} +
)} + + {historyPanel} + {weightsPanel} + +
+ {participantsPanel} +
+ + {layoutMode === 'commentsBottom' && ( +
+ {participantsPanel} +
)} +
+
+ + {layoutMode !== 'commentsBottom' && ( + + {commentsPanel} + {participantsPanel} + )} +
+
) } +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)} +
+
))} +
) + + export default TheatreDetailPage diff --git a/frontend/src/types.ts b/frontend/src/types.ts index be240f1..49dc634 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -226,7 +226,7 @@ export type Theatre = { export type TheatreComment = | { theatreId: number no: number - deteled: false + deleted: false user: { id: number, name: string } | null content: string createdAt: string } @@ -234,7 +234,7 @@ export type TheatreComment = no: number deleted: true user: { id: number, name: string } | null - content null, + content: null, createdAt: string } export type TheatreProgramme = { @@ -243,6 +243,46 @@ export type TheatreProgramme = { post: Post createdAt: string } +export type TheatreSkipVoteStatus = { + votesCount: number + requiredCount: number + watchingUsersCount: number + voted: boolean } + +export type TheatreInfo = { + hostFlg: boolean + postId: number | null + postStartedAt: string | null + postElapsedMs: number | null + watchingUsers: Pick[] + skipVote: TheatreSkipVoteStatus + skipped?: boolean } + +export type TheatreSkipEvent = { + id: number + theatreId: number + post: Post + skippedByUser: Pick + voters: Pick[] + tags: Tag[] + programmePosition: number | null + createdAt: string } + +export type TheatrePostWeight = { + post: Post + weight: number + penalty: number + tags: Tag[] } + +export type TheatreTagPenalty = { + tag: Tag + penalty: number } + +export type TheatrePostSelectionWeights = { + tagPenalties: TheatreTagPenalty[] + lightestPosts: TheatrePostWeight[] + heaviestPosts: TheatrePostWeight[] } + export type User = { id: number name: string | null -- 2.34.1 From b1362d327c2b7502197cd61e075a633323c3a1f3 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 6 Jun 2026 20:29:34 +0900 Subject: [PATCH 03/12] #302 --- .../theatre_comments_controller.rb | 1 + .../theatre_skip_events_controller.rb | 4 +- .../app/controllers/theatres_controller.rb | 29 +++++++++- .../spec/requests/theatre_comments_spec.rb | 40 +++++++++++++ backend/spec/requests/theatres_spec.rb | 58 ++++++++++++++++++- .../src/pages/theatres/TheatreDetailPage.tsx | 22 ++++--- frontend/src/types.ts | 2 - 7 files changed, 138 insertions(+), 18 deletions(-) diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb index 7cbaa40..599a57e 100644 --- a/backend/app/controllers/theatre_comments_controller.rb +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -45,6 +45,7 @@ class TheatreCommentsController < ApplicationController comment = TheatreComment.find_by(theatre_id:, no:) return head :not_found unless comment + return head :forbidden unless comment.user == current_user comment.discard! diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb index 4ee35da..18ec914 100644 --- a/backend/app/controllers/theatre_skip_events_controller.rb +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -6,7 +6,7 @@ class TheatreSkipEventsController < ApplicationController events = TheatreSkipEvent .where(theatre_id: params[:theatre_id]) - .includes(:skipped_by_user, :users, :tags, post: { tags: :tag_name }) + .includes(:tags, post: { tags: :tag_name }) .order(created_at: :desc) .limit(limit) @@ -14,8 +14,6 @@ class TheatreSkipEventsController < ApplicationController { id: event.id, theatre_id: event.theatre_id, post: PostRepr.base(event.post), - skipped_by_user: UserRepr.base(event.skipped_by_user), - voters: event.users.map { |user| UserRepr.base(user) }, tags: event.tags.map { |tag| TagRepr.inline(tag) }, programme_position: event.programme_position, created_at: event.created_at } diff --git a/backend/app/controllers/theatres_controller.rb b/backend/app/controllers/theatres_controller.rb index 8c864e2..10f4455 100644 --- a/backend/app/controllers/theatres_controller.rb +++ b/backend/app/controllers/theatres_controller.rb @@ -54,8 +54,11 @@ class TheatresController < ApplicationController theatre = Theatre.find_by(id: params[:id]) return head :not_found unless theatre + requested_post_id = params[:post_id].to_i + return head :unprocessable_entity if requested_post_id <= 0 skipped = false + conflicted = false ApplicationRecord.transaction do theatre.lock! @@ -64,7 +67,12 @@ class TheatresController < ApplicationController _1.expires_at = 30.seconds.from_now }.save! - TheatreSkipVote.find_or_create_by!(theatre:, post: theatre.current_post, user: current_user) + if theatre.current_post_id != requested_post_id + conflicted = true + next + end + + TheatreSkipVote.find_or_create_by!(theatre:, post_id: requested_post_id, user: current_user) vote_status = skip_vote_status(theatre) if vote_status[:votes_count] >= vote_status[:required_count] @@ -76,6 +84,8 @@ class TheatresController < ApplicationController end theatre.reload + return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted + render json: theatre_info_json(theatre, skipped:) end @@ -84,11 +94,24 @@ class TheatresController < ApplicationController theatre = Theatre.find_by(id: params[:id]) return head :not_found unless theatre + requested_post_id = params[:post_id].to_i + return head :unprocessable_entity if requested_post_id <= 0 - if theatre.current_post - TheatreSkipVote.where(theatre:, post: theatre.current_post, user: current_user).delete_all + conflicted = false + + theatre.with_lock do + if theatre.current_post + if theatre.current_post_id != requested_post_id + conflicted = true + else + TheatreSkipVote.where(theatre:, post_id: requested_post_id, user: current_user).delete_all + end + end end + theatre.reload + return render json: theatre_info_json(theatre, skipped: false), status: :conflict if conflicted + render json: theatre_info_json(theatre, skipped: false) end diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb index 856b309..5f41395 100644 --- a/backend/spec/requests/theatre_comments_spec.rb +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -147,4 +147,44 @@ RSpec.describe 'TheatreComments', type: :request do }) end end + + describe 'DELETE /theatres/:theatre_id/comments/:id' do + let(:theatre) { create(:theatre) } + let(:alice) { create(:user, name: 'Alice') } + let(:bob) { create(:user, name: 'Bob') } + let!(:comment) do + create( + :theatre_comment, + theatre: theatre, + no: 1, + user: alice, + content: 'delete target' + ) + end + + it 'returns 401 when not logged in' do + delete "/theatres/#{theatre.id}/comments/#{comment.no}" + + expect(response).to have_http_status(:unauthorized) + expect(comment.reload.discarded?).to eq(false) + end + + it 'allows the comment owner to delete it' do + sign_in_as(alice) + + delete "/theatres/#{theatre.id}/comments/#{comment.no}" + + expect(response).to have_http_status(:no_content) + expect(comment.reload.discarded?).to eq(true) + end + + it 'returns 403 when another user tries to delete it' do + sign_in_as(bob) + + delete "/theatres/#{theatre.id}/comments/#{comment.no}" + + expect(response).to have_http_status(:forbidden) + expect(comment.reload.discarded?).to eq(false) + end + end end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 16d0de7..00ab143 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -320,10 +320,11 @@ RSpec.describe 'Theatres API', type: :request do describe 'PUT /theatres/:id/skip_vote' do subject(:do_request) do - put "/theatres/#{theatre.id}/skip_vote" + put "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id } end let(:third_user) { create(:user, :member, name: 'third user') } + let(:requested_post_id) { niconico_post.id } before do theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) @@ -374,9 +375,25 @@ RSpec.describe 'Theatres API', type: :request do expect(event.tags).to contain_exactly(tag) expect(TheatreSkipVote.where(theatre:, post: niconico_post)).to be_empty end + + it 'does not record a vote when requested post is no longer current' do + theatre.update!(current_post: second_niconico_post) + sign_in_as(member) + + expect { do_request }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:conflict) + expect(json['post_id']).to eq(second_niconico_post.id) + expect(json['skip_vote']).to include( + 'votes_count' => 0, + 'voted' => false + ) + end end describe 'DELETE /theatres/:id/skip_vote' do + let(:requested_post_id) { niconico_post.id } + before do theatre.update!(current_post: niconico_post, current_post_started_at: 10.seconds.ago) TheatreWatchingUser.create!(theatre:, user: member, expires_at: 10.seconds.from_now) @@ -386,7 +403,7 @@ RSpec.describe 'Theatres API', type: :request do it 'removes the current user vote' do expect { - delete "/theatres/#{theatre.id}/skip_vote" + delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id } }.to change(TheatreSkipVote, :count).by(-1) expect(response).to have_http_status(:ok) @@ -397,6 +414,43 @@ RSpec.describe 'Theatres API', type: :request do 'voted' => false ) end + + it 'does not remove a vote when requested post is no longer current' do + theatre.update!(current_post: second_niconico_post) + + expect { + delete "/theatres/#{theatre.id}/skip_vote", params: { post_id: requested_post_id } + }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:conflict) + expect(json['post_id']).to eq(second_niconico_post.id) + end + end + + describe 'GET /theatres/:id/skip_events' do + before do + sign_in_as(member) + end + + it 'does not expose skip voters' do + event = TheatreSkipEvent.create!( + theatre:, + post: niconico_post, + skipped_by_user: member, + created_at: Time.current + ) + TheatreSkipEventVoter.create!(theatre_skip_event: event, user: member) + + get "/theatres/#{theatre.id}/skip_events" + + expect(response).to have_http_status(:ok) + expect(json.first).to include( + 'id' => event.id, + 'theatre_id' => theatre.id + ) + expect(json.first).not_to have_key('voters') + expect(json.first).not_to have_key('skipped_by_user') + end end describe 'GET /theatres/:id/post_selection_weights' do diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index bf68703..6a7d103 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -55,9 +55,9 @@ const LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LAYOUT_LABELS: Record = { - threeColumns: '3 コラム', - tagsBottom: 'タグ下', - commentsBottom: 'コメント下' } + threeColumns: '3 列', + tagsBottom: '2 列(コメント欄)', + commentsBottom: '2 列(タグ欄)' } const TAG_FLOW_LABELS: Record = { vertical: 'タグ縦', @@ -118,8 +118,9 @@ const tagsByCategory = (tags: Tag[]): Partial> => { } -const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = -({ tags, compact, flow = 'vertical' }) => { +const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = ( + { tags, compact, flow = 'vertical' }, +) => { const grouped = tagsByCategory (tags) if (flow === 'horizontal') @@ -463,8 +464,10 @@ const TheatreDetailPage: FC = ({ user }: Props) => { { const nextInfo = theatreInfo.skipVote.voted - ? await apiDelete (`/theatres/${ id }/skip_vote`) - : await apiPut (`/theatres/${ id }/skip_vote`) + ? await apiDelete ( + `/theatres/${ id }/skip_vote`, { params: { post_id: post.id } }) + : await apiPut ( + `/theatres/${ id }/skip_vote`, { post_id: post.id }) applyTheatreInfo (nextInfo) @@ -477,6 +480,9 @@ const TheatreDetailPage: FC = ({ user }: Props) => { } catch (error) { + if (isApiError (error) && error.response?.status === 409) + applyTheatreInfo (await apiPut (`/theatres/${ id }/watching`)) + console.error (error) } finally @@ -635,7 +641,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { {theatreInfo.watchingUsers.map (watchingUser => (
{userName (watchingUser)} - {watchingUser.id === user?.id && 自分} + {watchingUser.id === user?.id && お前}
))}
) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 49dc634..746991c 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -262,8 +262,6 @@ export type TheatreSkipEvent = { id: number theatreId: number post: Post - skippedByUser: Pick - voters: Pick[] tags: Tag[] programmePosition: number | null createdAt: string } -- 2.34.1 From 364d154b6a609b78b48d3e92c90bfaae47fd0218 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 00:05:18 +0900 Subject: [PATCH 04/12] #302 --- AGENTS.md | 71 ++- backend/AGENTS.md | 101 +++- .../theatre_programmes_controller.rb | 3 + backend/app/models/tag.rb | 2 +- backend/app/services/theatre_post_selector.rb | 68 ++- frontend/AGENTS.md | 80 +++- frontend/src/lib/users.ts | 1 + .../src/pages/theatres/TheatreDetailPage.tsx | 439 ++++++++++-------- 8 files changed, 482 insertions(+), 283 deletions(-) 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)} +
+
)))}
) -- 2.34.1 From a50c29cc350569fe2efa830b3aeebe5daf13deb9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 00:14:36 +0900 Subject: [PATCH 05/12] #302 --- AGENTS.md | 14 ++++++++++++-- backend/AGENTS.md | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21f6ccb..059080e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,10 +104,18 @@ npm run preview - Prefer precise, minimal changes. - Do not flatter or over-explain. - Explain risks directly. -- 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. - Ruby: never put a space before method-call parentheses. +- Ruby: never put a line break immediately before `)`. - Ruby: do not use `%w` or `%i`. -- TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid. +- Ruby hashes are not blocks; keep `}` on the same line as the final pair. +- Ruby hashes keep the first pair on the same line as `{` unless line length + requires a break. +- Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body + indentation. +- 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. @@ -128,6 +136,8 @@ npm run preview - 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 sensitive to N+1 queries; avoid introducing them and proactively fix + existing N+1 issues in the code path being edited. - Keep migration files and `backend/db/schema.rb` consistent when changing schema. ## Frontend rules diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 773b8ee..622272b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -68,15 +68,22 @@ service, representation, and spec. - Prefer precise, minimal changes. - Use single quotes unless interpolation or escaping makes double quotes better. - Do not put a space before Ruby method-call parentheses. +- Never put a line break immediately before `)` in Ruby. - 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. +- Treat Ruby hash `{ ... }` style and Ruby block `{ ... }` style as separate + rules. +- Do not format Ruby hashes like Ruby blocks. +- For Ruby hashes, keep the closing `}` on the same line as the final pair. +- Keep the first pair on the same line as `{` by default. +- If the hash would exceed the line limit, break after `{` and indent pairs + by 4 spaces. +- Put one logical pair per line when the expression would otherwise become + dense. +- For Ruby blocks, use 2-space indentation for the block body. - Keep comments short and useful; avoid narrating obvious code. - Do not add production dependencies without approval. @@ -186,6 +193,9 @@ service, representation, and spec. - When a controller action serializes nested associations, preload the associations it will touch instead of allowing N+1 queries. +- Be sensitive to N+1 queries in all backend work. +- Avoid introducing N+1 queries, and proactively fix existing N+1 issues when + you find them in the code path you are editing. - When an association may already be preloaded, prefer loaded-association checks that reuse the preloaded data without losing the efficient database path. -- 2.34.1 From 4b26f017b4ee555dece645dcb0319ccbf16e13a1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 00:26:18 +0900 Subject: [PATCH 06/12] #302 --- frontend/src/pages/theatres/TheatreDetailPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 4369b5b..df44f6f 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -340,7 +340,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { if (!(cancelled) && newComments.length > 0) { - lastCommentNoRef.current = newComments[newComments.length - 1].no + lastCommentNoRef.current = newComments[0].no setComments (prev => [...newComments, ...prev]) } -- 2.34.1 From 69820265fd4e5b7d4b7fd163b4bb38c886c60bb2 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 00:41:35 +0900 Subject: [PATCH 07/12] #302 --- backend/db/schema.rb | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/backend/db/schema.rb b/backend/db/schema.rb index ad3325b..b6fef2f 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -137,19 +137,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" end - create_table "post_tag_sections", primary_key: ["post_id", "tag_id", "begin_ms"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| - t.bigint "post_id", null: false - t.bigint "tag_id", null: false - t.integer "begin_ms", null: false - t.integer "end_ms", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.index ["post_id", "begin_ms"], name: "idx_post_tag_sections_post_id_begin_ms" - t.index ["tag_id"], name: "fk_rails_8be3847903" - t.check_constraint "`begin_ms` < `end_ms`", name: "chk_post_tag_sections_end_ms_after_begin_ms" - t.check_constraint "`begin_ms` >= 0", name: "chk_post_tag_sections_begin_ms_natural" - end - create_table "post_tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "post_id", null: false t.bigint "tag_id", null: false @@ -200,11 +187,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.datetime "original_created_before" t.datetime "updated_at", null: false t.integer "version_no", null: false - t.integer "video_ms" t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" t.index ["url"], name: "index_posts_on_url", unique: true - t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" - t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive" end @@ -512,8 +496,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do add_foreign_key "post_implications", "posts", column: "parent_post_id" add_foreign_key "post_similarities", "posts" add_foreign_key "post_similarities", "posts", column: "target_post_id" - add_foreign_key "post_tag_sections", "posts" - add_foreign_key "post_tag_sections", "tags" add_foreign_key "post_tags", "posts" add_foreign_key "post_tags", "tags" add_foreign_key "post_tags", "users", column: "created_user_id" -- 2.34.1 From 39d86f477821a974391e5edccad9b95781ac2add Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 01:24:44 +0900 Subject: [PATCH 08/12] #302 --- .../theatre_skip_events_controller.rb | 6 +-- backend/app/services/theatre_post_selector.rb | 40 +++++++------------ backend/db/schema.rb | 1 - .../src/pages/theatres/TheatreDetailPage.tsx | 19 +++++++-- 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/backend/app/controllers/theatre_skip_events_controller.rb b/backend/app/controllers/theatre_skip_events_controller.rb index 18ec914..fbbdae9 100644 --- a/backend/app/controllers/theatre_skip_events_controller.rb +++ b/backend/app/controllers/theatre_skip_events_controller.rb @@ -6,15 +6,15 @@ class TheatreSkipEventsController < ApplicationController events = TheatreSkipEvent .where(theatre_id: params[:theatre_id]) - .includes(:tags, post: { tags: :tag_name }) + .includes(:post, tags: :tag_name) .order(created_at: :desc) .limit(limit) render json: events.map { |event| { id: event.id, theatre_id: event.theatre_id, - post: PostRepr.base(event.post), - tags: event.tags.map { |tag| TagRepr.inline(tag) }, + post: { id: event.post.id, title: event.post.title, url: event.post.url }, + tags: event.tags.map { |tag| { id: tag.id, name: tag.name } }, programme_position: event.programme_position, created_at: event.created_at } } diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb index 2da5092..51efb84 100644 --- a/backend/app/services/theatre_post_selector.rb +++ b/backend/app/services/theatre_post_selector.rb @@ -24,11 +24,9 @@ class TheatrePostSelector 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 @@ -45,11 +43,10 @@ class TheatrePostSelector penalty = post_tags.sum { |tag| penalties[tag.id].to_i } Candidate.new( - post:, - penalty:, - tags: post_tags, - weight: 1.0 / (1.0 + penalty) - ) + post:, + penalty:, + tags: post_tags, + weight: 1.0 / (1.0 + penalty)) end end end @@ -87,10 +84,8 @@ class TheatrePostSelector tag = tags[tag_id] next unless tag - { - tag: light_tag_json(tag), - penalty: - } + { tag: light_tag_json(tag), + penalty: } } .compact .sort_by { |row| [-row[:penalty], row[:tag][:name].to_s] } @@ -98,27 +93,22 @@ class TheatrePostSelector def post_weight_json candidates candidates.map { |candidate| - { - post: light_post_json(candidate.post), + { post: light_post_json(candidate.post), weight: candidate.weight, penalty: candidate.penalty, - tags: candidate.tags.map { |tag| light_tag_json(tag) } - } + tags: candidate.tags.map { |tag| light_tag_json(tag) } } } end def light_post_json post - { - id: post.id, + { id: post.id, title: post.title, - url: post.url - } + url: post.url } end def light_tag_json tag - { - id: tag.id, - name: tag.name - } + { id: tag.id, + name: tag.name, + category: tag.category } end end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index b6fef2f..9fe2736 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -339,7 +339,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_06_06_000000) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["expires_at"], name: "index_theatre_watching_users_on_expires_at" - t.index ["theatre_id", "expires_at"], name: "idx_on_theatre_id_skip_expires_at_4c8de1dd42" t.index ["theatre_id", "expires_at"], name: "index_theatre_watching_users_on_theatre_id_and_expires_at" t.index ["theatre_id"], name: "index_theatre_watching_users_on_theatre_id" t.index ["user_id"], name: "index_theatre_watching_users_on_user_id" diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index df44f6f..e3e1fb3 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -22,6 +22,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC, FormEvent, ReactNode } from 'react' import type { NiconicoMetadata, + NiconicoVideoInfo, NiconicoViewerHandle, Post, Category, @@ -459,6 +460,19 @@ const TheatreDetailPage: FC = ({ user }: Props) => { await advancePost () } + const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { + const lengthMs = info.lengthInSeconds * 1_000 + setVideoLength (lengthMs) + + if (lengthMs <= 0) + { + void handlePlaybackError () + return + } + + embedRef.current?.play () + } + const handleSkipVote = async () => { if (!(id) || !(post)) return @@ -797,10 +811,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { key={post.id} ref={embedRef} post={post} - onLoadComplete={info => { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} + onLoadComplete={handleNiconicoLoadComplete} onMetadataChange={syncPlayback} onError={handlePlaybackError}/>) : (
-- 2.34.1 From be2df723fe6274b5d6a6eba93a4b795690f6e0a6 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:01:16 +0900 Subject: [PATCH 09/12] #302 --- AGENTS.md | 4 + backend/AGENTS.md | 4 + backend/app/services/theatre_post_selector.rb | 7 +- backend/spec/requests/theatres_spec.rb | 26 +++++- frontend/AGENTS.md | 4 + frontend/src/components/NicoViewer.tsx | 89 ++++++++++++------ frontend/src/components/PostEmbed.tsx | 92 ++++++++++++++++++- .../src/pages/theatres/TheatreDetailPage.tsx | 43 ++++++--- 8 files changed, 222 insertions(+), 47 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 059080e..16aa3c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,10 @@ npm run preview requires a break. - Ruby blocks use separate `{ ... }` rules from hashes, with 2-space body indentation. +- For arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - TypeScript and Python: use GNU-style spacing before parentheses where syntactically valid. - Never write Ruby, TypeScript, or TSX lines longer than 99 characters. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 622272b..9553712 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -83,6 +83,10 @@ service, representation, and spec. by 4 spaces. - Put one logical pair per line when the expression would otherwise become dense. +- For Ruby arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - For Ruby blocks, use 2-space indentation for the block body. - Keep comments short and useful; avoid narrating obvious code. - Do not add production dependencies without approval. diff --git a/backend/app/services/theatre_post_selector.rb b/backend/app/services/theatre_post_selector.rb index 51efb84..124c913 100644 --- a/backend/app/services/theatre_post_selector.rb +++ b/backend/app/services/theatre_post_selector.rb @@ -1,5 +1,10 @@ class TheatrePostSelector Candidate = Struct.new(:post, :weight, :penalty, :tags, keyword_init: true) + ELIGIBLE_POST_URL_CONDITION = + ["url LIKE '%nicovideo.jp%'", + "url LIKE '%youtube.com/watch%'", + "url LIKE '%youtu.be/%'"] + .join(' OR ') def initialize theatre: @theatre = theatre @@ -52,7 +57,7 @@ class TheatrePostSelector end def eligible_posts - posts = Post.where("url LIKE '%nicovideo.jp%'") + posts = Post.where(ELIGIBLE_POST_URL_CONDITION) posts = posts.where.not(id: theatre.current_post_id) if theatre.current_post_id posts end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 00ab143..4a5a198 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -28,6 +28,13 @@ RSpec.describe 'Theatres API', type: :request do ) end + let!(:youtube_post) do + Post.create!( + title: 'youtube post', + url: 'https://www.youtube.com/watch?v=yt123' + ) + end + let!(:other_post) do Post.create!( title: 'other post', @@ -286,7 +293,7 @@ RSpec.describe 'Theatres API', type: :request do .to change { theatre.reload.current_post_id } expect(response).to have_http_status(:no_content) - expect([niconico_post.id, second_niconico_post.id]) + expect([niconico_post.id, second_niconico_post.id, youtube_post.id]) .to include(theatre.reload.current_post_id) expect(theatre.reload.current_post_started_at) .to be_within(1.second).of(Time.current) @@ -294,10 +301,27 @@ RSpec.describe 'Theatres API', type: :request do end end + context 'when only a YouTube post is eligible' do + before do + niconico_post.destroy! + second_niconico_post.destroy! + theatre.update!(host_user: member) + sign_in_as(member) + end + + it 'sets current_post to the YouTube post' do + do_request + + expect(response).to have_http_status(:no_content) + expect(theatre.reload.current_post_id).to eq(youtube_post.id) + end + end + context 'when current user is host and no eligible post exists' do before do niconico_post.destroy! second_niconico_post.destroy! + youtube_post.destroy! theatre.update!( host_user: member, current_post: other_post, diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9150c92..124c9b7 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -47,6 +47,10 @@ If either command cannot be run or fails, report the exact command and failure. - 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. +- For arrays, never put whitespace or a line break immediately before `]`. +- Keep the first element on the same line as `[` by default. +- If an array would exceed the line limit, break after `[` and indent + elements by 4 spaces. - 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. diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index ed65823..4078ef3 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -14,10 +14,20 @@ import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle } from ' type NiconicoPlayerMessage = | { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' } - | { eventName: 'loadComplete'; playerId?: string; data: { videoInfo: NiconicoVideoInfo } } - | { eventName: 'playerMetadataChange'; playerId?: string; data: NiconicoMetadata } - | { eventName: 'playerStatusChange' | 'statusChange'; playerId?: string; data?: unknown } - | { eventName: 'error'; playerId?: string; data?: unknown; code?: string; message?: string } + | { eventName: 'loadComplete' + playerId?: string + data: { videoInfo: NiconicoVideoInfo } } + | { eventName: 'playerMetadataChange' + playerId?: string + data: NiconicoMetadata } + | { eventName: 'playerStatusChange' | 'statusChange' + playerId?: string + data?: unknown } + | { eventName: 'error' + playerId?: string + data?: unknown + code?: string + message?: string } type NiconicoCommand = | { eventName: 'play'; sourceConnectorType: 1; playerId: string } @@ -30,6 +40,7 @@ type NiconicoCommand = data: { commentVisibility: boolean } } const EMBED_ORIGIN = 'https://embed.nicovideo.jp' +const LOAD_COMPLETE_TIMEOUT_MS = 8_000 type Props = { id: string @@ -42,10 +53,13 @@ type Props = { export default forwardRef ((props: Props, ref: ForwardedRef) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props - const iframeRef = useRef (null) - const playerId = useMemo (() => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, [id]) + const iframeRef = useRef (null) + const loadCompleteTimerRef = useRef | null> (null) + const playerId = useMemo ( + () => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, + [id]) const [screenWidth, setScreenWidth] = useState () const [screenHeight, setScreenHeight] = useState () @@ -77,8 +91,26 @@ export default forwardRef ((props: Props, ref: ForwardedRef { + if (!(loadCompleteTimerRef.current)) + return + + clearTimeout (loadCompleteTimerRef.current) + loadCompleteTimerRef.current = null + }, []) + + const startLoadCompleteTimer = useCallback (() => { + clearLoadCompleteTimer () + loadCompleteTimerRef.current = setTimeout (() => { + onError?.({ + eventName: 'loadCompleteTimeout', + reason: 'niconico video length was not reported by embed', + }) + }, LOAD_COMPLETE_TIMEOUT_MS) + }, [clearLoadCompleteTimer, onError]) const postToPlayer = useCallback ((message: NiconicoCommand) => { const win = iframeRef.current?.contentWindow @@ -161,11 +193,12 @@ export default forwardRef ((props: Props, ref: ForwardedRef removeEventListener ('message', onMessage) - }, [onError, onLoadComplete, onMetadataChange, playerId]) + return () => removeEventListener ('message', onMessage) + }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) + + useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer]) useLayoutEffect (() => { if (!(fullScreen)) @@ -235,9 +271,10 @@ export default forwardRef ((props: Props, ref: ForwardedRef) + width={width} + height={height} + style={margedStyle} + onLoad={startLoadCompleteTimer} + allowFullScreen + allow="autoplay"/>) }) diff --git a/frontend/src/components/PostEmbed.tsx b/frontend/src/components/PostEmbed.tsx index 6c04f48..ac7f91d 100644 --- a/frontend/src/components/PostEmbed.tsx +++ b/frontend/src/components/PostEmbed.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import YoutubeEmbed from 'react-youtube' import NicoViewer from '@/components/NicoViewer' @@ -8,18 +8,97 @@ import { useDialogue } from '@/components/dialogues/DialogueProvider' import type { FC, RefObject } from 'react' import type { NiconicoMetadata, NiconicoVideoInfo, NiconicoViewerHandle, Post } from '@/types' +import type { YouTubePlayer } from 'react-youtube' + +type YouTubeEvent = { + data: T + target: YouTubePlayer } type Props = { ref?: RefObject post: Post onLoadComplete?: (info: NiconicoVideoInfo) => void onMetadataChange?: (meta: NiconicoMetadata) => void + onVideoReady?: (durationMs: number) => void + onPlaybackChange?: (currentTimeMs: number) => number | void onError?: (data: unknown) => void } -const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onError }) => { +const PostEmbed: FC = ({ + ref, + post, + onLoadComplete, + onMetadataChange, + onVideoReady, + onPlaybackChange, + onError, +}) => { const dialogue = useDialogue () const [framed, setFramed] = useState (false) + const [youtubePlayer, setYoutubePlayer] = useState (null) + + const reportYoutubePlayback = useCallback (async (player: YouTubePlayer) => { + const currentTime = await player.getCurrentTime () + const currentTimeMs = currentTime * 1_000 + const targetTimeMs = onPlaybackChange?.(currentTimeMs) + + if (typeof targetTimeMs !== 'number') + return + + if (Math.abs (currentTimeMs - targetTimeMs) > 5_000) + await player.seekTo (targetTimeMs / 1_000, true) + }, [onPlaybackChange]) + + const handleYoutubeReady = async (event: YouTubeEvent) => { + setYoutubePlayer (event.target) + + try + { + await event.target.playVideo () + + const duration = await event.target.getDuration () + const durationMs = duration * 1_000 + onVideoReady?.(durationMs) + + if (!(Number.isFinite (durationMs)) || durationMs <= 0) + return + + await reportYoutubePlayback (event.target) + } + catch (error) + { + onError?.({ platform: 'youtube', error }) + } + } + + const handleYoutubeStateChange = (event: YouTubeEvent) => { + void reportYoutubePlayback (event.target) + } + + const handleYoutubeError = (event: YouTubeEvent) => { + onError?.({ platform: 'youtube', code: event.data }) + } + + const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { + onVideoReady?.(info.lengthInSeconds * 1_000) + onLoadComplete?.(info) + } + + const handleNiconicoMetadataChange = (meta: NiconicoMetadata) => { + onPlaybackChange?.(meta.currentTime) + onMetadataChange?.(meta) + } + + useEffect (() => { + if (!(youtubePlayer) || !(onPlaybackChange)) + return + + const timer = setInterval ( + () => void reportYoutubePlayback (youtubePlayer), + 1_000) + + return () => clearInterval (timer) + }, [onPlaybackChange, reportYoutubePlayback, youtubePlayer]) const url = new URL (post.url) @@ -39,8 +118,8 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onE id={videoId} width={640} height={360} - onLoadComplete={onLoadComplete} - onMetadataChange={onMetadataChange} + onLoadComplete={handleNiconicoLoadComplete} + onMetadataChange={handleNiconicoMetadataChange} onError={onError}/>) } @@ -71,7 +150,10 @@ const PostEmbed: FC = ({ ref, post, onLoadComplete, onMetadataChange, onE mute: 0, loop: 1, width: '640', - height: '360' } }}/>) + height: '360' } }} + onReady={handleYoutubeReady} + onStateChange={handleYoutubeStateChange} + onError={handleYoutubeError}/>) } } diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index e3e1fb3..519820b 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -21,9 +21,7 @@ import { useValidationErrors } from '@/lib/useValidationErrors' import type { FC, FormEvent, ReactNode } from 'react' -import type { NiconicoMetadata, - NiconicoVideoInfo, - NiconicoViewerHandle, +import type { NiconicoViewerHandle, Post, Category, Tag, @@ -88,7 +86,9 @@ const commentBox = ( {dateString (comment.createdAt)}
), ( -
+
{programme && ( <> @@ -439,7 +439,7 @@ const TheatreDetailPage: FC = ({ user }: Props) => { void refreshProgrammes () }, [refreshProgrammes, theatreInfo.postId]) - const syncPlayback = (meta: NiconicoMetadata) => { + const syncPlaybackTime = (currentTimeMs: number): number | void => { if (!(theatreInfo.postStartedAt)) return @@ -447,24 +447,38 @@ const TheatreDetailPage: FC = ({ user }: Props) => { currentPostElapsedMs (theatreInfo), videoLength) - const drift = Math.abs (meta.currentTime - targetTime) + const drift = Math.abs (currentTimeMs - targetTime) if (drift > 5_000) embedRef.current?.seek (targetTime) + + return targetTime } const handlePlaybackError = async () => { if (!(theatreInfoRef.current.hostFlg) || loadingRef.current) return - await advancePost () + loadingRef.current = true + try + { + await advancePost () + } + finally + { + loadingRef.current = false + } } - const handleNiconicoLoadComplete = (info: NiconicoVideoInfo) => { - const lengthMs = info.lengthInSeconds * 1_000 - setVideoLength (lengthMs) + const handleVideoReady = (durationMs: number) => { + const playableDurationMs = + Number.isFinite (durationMs) + ? durationMs + : 0 - if (lengthMs <= 0) + setVideoLength (playableDurationMs) + + if (playableDurationMs <= 0) { void handlePlaybackError () return @@ -738,7 +752,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { + className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950 + md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50"> {theatre && {`${ theatreTitle } | ${ SITE_TITLE }`}} @@ -811,8 +826,8 @@ const TheatreDetailPage: FC = ({ user }: Props) => { key={post.id} ref={embedRef} post={post} - onLoadComplete={handleNiconicoLoadComplete} - onMetadataChange={syncPlayback} + onVideoReady={handleVideoReady} + onPlaybackChange={syncPlaybackTime} onError={handlePlaybackError}/>) : (
{loading ? '次の投稿を選んでゐます……' : '上映待機中'} -- 2.34.1 From 6e338c86166bb54f8a84a325615b48d368f82fee Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:13:40 +0900 Subject: [PATCH 10/12] #302 --- frontend/src/components/NicoViewer.tsx | 146 ++++++++++++------------- 1 file changed, 73 insertions(+), 73 deletions(-) diff --git a/frontend/src/components/NicoViewer.tsx b/frontend/src/components/NicoViewer.tsx index 4078ef3..c407391 100644 --- a/frontend/src/components/NicoViewer.tsx +++ b/frontend/src/components/NicoViewer.tsx @@ -1,11 +1,11 @@ import { forwardRef, - useCallback, - useEffect, - useImperativeHandle, - useLayoutEffect, - useMemo, - useRef, - useState } from 'react' + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState } from 'react' import type { CSSProperties, ForwardedRef } from 'react' @@ -15,19 +15,19 @@ type NiconicoPlayerMessage = | { eventName: 'enterProgrammaticFullScreen' } | { eventName: 'exitProgrammaticFullScreen' } | { eventName: 'loadComplete' - playerId?: string - data: { videoInfo: NiconicoVideoInfo } } + playerId?: string + data: { videoInfo: NiconicoVideoInfo } } | { eventName: 'playerMetadataChange' - playerId?: string - data: NiconicoMetadata } + playerId?: string + data: NiconicoMetadata } | { eventName: 'playerStatusChange' | 'statusChange' - playerId?: string - data?: unknown } + playerId?: string + data?: unknown } | { eventName: 'error' - playerId?: string - data?: unknown - code?: string - message?: string } + playerId?: string + data?: unknown + code?: string + message?: string } type NiconicoCommand = | { eventName: 'play'; sourceConnectorType: 1; playerId: string } @@ -53,13 +53,13 @@ type Props = { export default forwardRef ((props: Props, ref: ForwardedRef) => { - const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props + const { id, width, height, style = { }, onLoadComplete, onMetadataChange, onError } = props - const iframeRef = useRef (null) - const loadCompleteTimerRef = useRef | null> (null) - const playerId = useMemo ( - () => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, - [id]) + const iframeRef = useRef (null) + const loadCompleteTimerRef = useRef | null> (null) + const playerId = useMemo ( + () => `nico-${ id }-${ Math.random ().toString (36).slice (2) }`, + [id]) const [screenWidth, setScreenWidth] = useState () const [screenHeight, setScreenHeight] = useState () @@ -79,38 +79,38 @@ export default forwardRef ((props: Props, ref: ForwardedRef { - if (!(loadCompleteTimerRef.current)) - return + const clearLoadCompleteTimer = useCallback (() => { + if (!(loadCompleteTimerRef.current)) + return - clearTimeout (loadCompleteTimerRef.current) - loadCompleteTimerRef.current = null - }, []) + clearTimeout (loadCompleteTimerRef.current) + loadCompleteTimerRef.current = null + }, []) - const startLoadCompleteTimer = useCallback (() => { - clearLoadCompleteTimer () - loadCompleteTimerRef.current = setTimeout (() => { - onError?.({ - eventName: 'loadCompleteTimeout', - reason: 'niconico video length was not reported by embed', - }) - }, LOAD_COMPLETE_TIMEOUT_MS) - }, [clearLoadCompleteTimer, onError]) + const startLoadCompleteTimer = useCallback (() => { + clearLoadCompleteTimer () + loadCompleteTimerRef.current = setTimeout (() => { + onError?.({ + eventName: 'loadCompleteTimeout', + reason: 'niconico video length was not reported by embed', + }) + }, LOAD_COMPLETE_TIMEOUT_MS) + }, [clearLoadCompleteTimer, onError]) const postToPlayer = useCallback ((message: NiconicoCommand) => { const win = iframeRef.current?.contentWindow @@ -165,21 +165,21 @@ export default forwardRef ((props: Props, ref: ForwardedRef { const onMessage = (event: MessageEvent) => { if (!(iframeRef.current) - || (event.source !== iframeRef.current.contentWindow) - || (event.origin !== EMBED_ORIGIN)) - return + || (event.source !== iframeRef.current.contentWindow) + || (event.origin !== EMBED_ORIGIN)) + return const data = event.data if (!(data) || typeof data !== 'object' || !('eventName' in data)) - return + return if (('playerId' in data) && data.playerId && data.playerId !== playerId) - return + return if (data.eventName === 'enterProgrammaticFullScreen') { @@ -193,12 +193,12 @@ export default forwardRef ((props: Props, ref: ForwardedRef removeEventListener ('message', onMessage) - }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) + return () => removeEventListener ('message', onMessage) + }, [clearLoadCompleteTimer, onError, onLoadComplete, onMetadataChange, playerId]) - useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer]) + useEffect (() => clearLoadCompleteTimer, [clearLoadCompleteTimer]) useLayoutEffect (() => { if (!(fullScreen)) @@ -232,7 +232,7 @@ export default forwardRef ((props: Props, ref: ForwardedRef { if (ended) - return + return const isLandscape = innerWidth >= innerHeight const windowWidth = `${ isLandscape ? innerWidth : innerHeight }px` @@ -246,9 +246,9 @@ export default forwardRef ((props: Props, ref: ForwardedRef { if (requestAnimationFrame) - requestAnimationFrame (pollingResize) + requestAnimationFrame (pollingResize) else - pollingResize () + pollingResize () } startPollingResize () -- 2.34.1 From 201fe72e5a5898759d82b37e21279a55842955b1 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:40:05 +0900 Subject: [PATCH 11/12] #302 --- backend/app/controllers/theatre_programmes_controller.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/app/controllers/theatre_programmes_controller.rb b/backend/app/controllers/theatre_programmes_controller.rb index 4f479f7..b386f63 100644 --- a/backend/app/controllers/theatre_programmes_controller.rb +++ b/backend/app/controllers/theatre_programmes_controller.rb @@ -9,14 +9,14 @@ 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 }] }]) + .includes(:post) .order(position: :desc).limit(100) .limit(limit) render json: programmes.map { |programme| - programme.as_json.merge(post: PostRepr.base(programme.post)) + programme.as_json.merge(post: { id: programme.post.id, + title: programme.post.title, + url: programme.post.url }) } end end -- 2.34.1 From 546a212e74d04cf9d003ab439ee5e60ec90d8da9 Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 7 Jun 2026 02:50:04 +0900 Subject: [PATCH 12/12] #302 --- .../spec/requests/theatre_comments_spec.rb | 20 ++ .../spec/requests/theatre_programmes_spec.rb | 7 + backend/spec/requests/theatres_spec.rb | 20 +- .../pages/theatres/TheatreDetailPage.test.tsx | 204 ++++++++++++++++++ frontend/src/test/factories.ts | 70 +++++- 5 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/theatres/TheatreDetailPage.test.tsx diff --git a/backend/spec/requests/theatre_comments_spec.rb b/backend/spec/requests/theatre_comments_spec.rb index 5f41395..78295be 100644 --- a/backend/spec/requests/theatre_comments_spec.rb +++ b/backend/spec/requests/theatre_comments_spec.rb @@ -80,6 +80,26 @@ RSpec.describe 'TheatreComments', type: :request do expect(response).to have_http_status(:ok) expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) end + + it '削除済みコメントは deleted として返し、本文を隠す' do + comment_2.discard! + + get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } + + expect(response).to have_http_status(:ok) + + deleted_comment = response.parsed_body.find { _1['no'] == 2 } + expect(deleted_comment).to include( + 'deleted' => true, + 'content' => nil + ) + + visible_comment = response.parsed_body.find { _1['no'] == 3 } + expect(visible_comment).to include( + 'deleted' => false, + 'content' => 'third comment' + ) + end end describe 'POST /theatres/:theatre_id/comments' do diff --git a/backend/spec/requests/theatre_programmes_spec.rb b/backend/spec/requests/theatre_programmes_spec.rb index 529b583..f37fa66 100644 --- a/backend/spec/requests/theatre_programmes_spec.rb +++ b/backend/spec/requests/theatre_programmes_spec.rb @@ -27,5 +27,12 @@ RSpec.describe 'TheatreProgrammes', type: :request do expect(json.map { _1.dig('post', 'title') }).to eq(['second', 'first']) expect(json.first['post']).to include('id' => post_2.id, 'url' => post_2.url) end + + it 'filters programmes by position_gt' do + get "/theatres/#{theatre.id}/programmes", params: { position_gt: 1 } + + expect(response).to have_http_status(:ok) + expect(json.map { _1['position'] }).to eq([2]) + end end end diff --git a/backend/spec/requests/theatres_spec.rb b/backend/spec/requests/theatres_spec.rb index 4a5a198..7fd3a0a 100644 --- a/backend/spec/requests/theatres_spec.rb +++ b/backend/spec/requests/theatres_spec.rb @@ -361,6 +361,24 @@ RSpec.describe 'Theatres API', type: :request do end end + it 'returns 401 when not logged in' do + sign_out + + expect { do_request }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 422 when post_id is invalid' do + sign_in_as(member) + + expect { + put "/theatres/#{theatre.id}/skip_vote", params: { post_id: 'invalid' } + }.not_to change(TheatreSkipVote, :count) + + expect(response).to have_http_status(:unprocessable_entity) + end + it 'records a vote and returns the current vote status before majority' do sign_in_as(member) @@ -391,7 +409,7 @@ RSpec.describe 'Theatres API', type: :request do expect(response).to have_http_status(:ok) expect(json['skipped']).to eq(true) - expect(json['post_id']).to eq(second_niconico_post.id) + expect([second_niconico_post.id, youtube_post.id]).to include(json['post_id']) event = TheatreSkipEvent.last expect(event.post).to eq(niconico_post) diff --git a/frontend/src/pages/theatres/TheatreDetailPage.test.tsx b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx new file mode 100644 index 0000000..3ba3409 --- /dev/null +++ b/frontend/src/pages/theatres/TheatreDetailPage.test.tsx @@ -0,0 +1,204 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import TheatreDetailPage from '@/pages/theatres/TheatreDetailPage' +import { buildPost, + buildTheatre, + buildTheatreComment, + buildTheatreInfo, + buildTheatrePostSelectionWeights, + buildTheatreProgramme, + buildUser } from '@/test/factories' +import { renderWithProviders } from '@/test/render' + +import type { ReactNode } from 'react' + +const api = vi.hoisted (() => ({ + apiDelete: vi.fn (), + apiGet: vi.fn (), + apiPatch: vi.fn (), + apiPost: vi.fn (), + apiPut: vi.fn (), + isApiError: vi.fn (() => false), +})) + +const postsApi = vi.hoisted (() => ({ + fetchPost: vi.fn (), +})) + +const dialogue = vi.hoisted (() => ({ + confirm: vi.fn (), +})) + +vi.mock ('@/lib/api', () => api) +vi.mock ('@/lib/posts', () => postsApi) +vi.mock ('@/components/dialogues/DialogueProvider', () => ({ + useDialogue: () => dialogue, +})) +vi.mock ('@/components/PostEmbed', () => ({ + default: ({ post }: { + post: { title: string | null; url: string } + }) =>
Embed:{post.title || post.url}
, +})) +vi.mock ('@/components/PostEditForm', () => ({ + default: () =>
Post edit form
, +})) +vi.mock ('framer-motion', () => ({ + motion: { + aside: ({ children }: { children?: ReactNode }) => , + div: ({ children }: { children?: ReactNode }) =>
{children}
, + main: ({ children }: { children?: ReactNode }) =>
{children}
, + }, +})) + +const currentPost = buildPost ({ + id: 10, + title: '上映中の投稿', + url: 'https://www.nicovideo.jp/watch/sm10', +}) +const theatre = buildTheatre ({ id: 7, name: '上映室' }) +const programme = buildTheatreProgramme ({ + theatreId: 7, + position: 3, + post: currentPost, +}) +const weights = buildTheatrePostSelectionWeights ({ + lightestPosts: [{ + post: currentPost, + penalty: 2, + weight: 0.5, + tags: [], + }], +}) + +const renderPage = (user = buildUser ({ id: 1, role: 'member' })) => + renderWithProviders ( + + }/> + , + { route: '/theatres/7' }, + ) + +const mockDefaultApi = () => { + api.apiGet.mockImplementation ((path: string) => { + switch (path) + { + case '/theatres/7': + return Promise.resolve (theatre) + + case '/theatres/7/comments': + return Promise.resolve ([ + buildTheatreComment ({ + theatreId: 7, + no: 2, + user: { id: 1, name: 'tester' }, + content: '視聴コメント', + }), + ]) + + case '/theatres/7/programmes': + return Promise.resolve ([programme]) + + case '/theatres/7/post_selection_weights': + return Promise.resolve (weights) + + default: + return Promise.reject (new Error (`Unexpected GET ${ path }`)) + } + }) + + api.apiPut.mockImplementation ((path: string) => { + switch (path) + { + case '/theatres/7/watching': + return Promise.resolve (buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 1_000, + watchingUsers: [{ id: 1, name: 'tester' }], + skipVote: { + votesCount: 0, + requiredCount: 2, + watchingUsersCount: 1, + voted: false, + }, + })) + + case '/theatres/7/skip_vote': + return Promise.resolve (buildTheatreInfo ({ + postId: currentPost.id, + postStartedAt: '2026-01-02T03:04:05.000Z', + postElapsedMs: 2_000, + watchingUsers: [{ id: 1, name: 'tester' }], + skipVote: { + votesCount: 1, + requiredCount: 2, + watchingUsersCount: 1, + voted: true, + }, + })) + + default: + return Promise.reject (new Error (`Unexpected PUT ${ path }`)) + } + }) + + api.apiDelete.mockResolvedValue (undefined) + api.apiPatch.mockResolvedValue (undefined) + api.apiPost.mockResolvedValue (undefined) + postsApi.fetchPost.mockResolvedValue (currentPost) + dialogue.confirm.mockResolvedValue (true) +} + +describe ('TheatreDetailPage', () => { + beforeEach (() => { + vi.clearAllMocks () + mockDefaultApi () + }) + + it ('loads theatre state, comments, current post, programme history, and weights', async () => { + renderPage () + + expect (await screen.findByText ('上映会場『上映室』')).toBeInTheDocument () + expect (await screen.findByText ('Embed:上映中の投稿')).toBeInTheDocument () + expect (screen.getAllByText ('視聴コメント')[0]).toBeInTheDocument () + expect (screen.getAllByText ('上映中の投稿')[0]).toBeInTheDocument () + expect (screen.getByText ('penalty 2')).toBeInTheDocument () + + await waitFor (() => { + expect (postsApi.fetchPost).toHaveBeenCalledWith ('10') + }) + }) + + it ('votes to skip the current post', async () => { + renderPage () + + await screen.findByText ('Embed:上映中の投稿') + + fireEvent.click (screen.getByRole ('button', { name: 'スキップ 0 / 2' })) + + await waitFor (() => { + expect (api.apiPut).toHaveBeenCalledWith ( + '/theatres/7/skip_vote', + { post_id: 10 }, + ) + }) + expect (await screen.findByRole ('button', { name: 'スキップ取消 1 / 2' })) + .toBeInTheDocument () + }) + + it ('deletes an owned comment after confirmation', async () => { + renderPage () + + fireEvent.click ((await screen.findAllByLabelText ('コメントを削除'))[0]) + + await waitFor (() => { + expect (dialogue.confirm).toHaveBeenCalled () + }) + await waitFor (() => { + expect (api.apiDelete).toHaveBeenCalledWith ('/theatres/7/comments/2') + }) + expect (await screen.findAllByText ('削除されました.')).toHaveLength (2) + }) +}) diff --git a/frontend/src/test/factories.ts b/frontend/src/test/factories.ts index 2092619..94a09ed 100644 --- a/frontend/src/test/factories.ts +++ b/frontend/src/test/factories.ts @@ -1,4 +1,13 @@ -import type { Material, Post, Tag, User, WikiPage } from '@/types' +import type { Material, + Post, + Tag, + Theatre, + TheatreComment, + TheatreInfo, + TheatrePostSelectionWeights, + TheatreProgramme, + User, + WikiPage } from '@/types' export const buildTag = (overrides: Partial = {}): Tag => ({ id: 1, @@ -72,3 +81,62 @@ export const buildMaterial = (overrides: Partial = {}): Material => ({ updatedByUser: { id: 2, name: 'updater' }, ...overrides, }) + +export const buildTheatre = (overrides: Partial = {}): Theatre => ({ + id: 1, + name: 'テスト劇場', + opensAt: '2026-01-02T03:04:05.000Z', + closesAt: null, + createdByUser: { id: 1, name: 'creator' }, + createdAt: '2026-01-02T03:04:05.000Z', + updatedAt: '2026-01-03T03:04:05.000Z', + ...overrides, +}) + +export const buildTheatreInfo = ( + overrides: Partial = {}, +): TheatreInfo => ({ + hostFlg: false, + postId: null, + postStartedAt: null, + postElapsedMs: null, + watchingUsers: [], + skipVote: { + votesCount: 0, + requiredCount: 1, + watchingUsersCount: 0, + voted: false, + }, + ...overrides, +}) + +export const buildTheatreComment = ( + overrides: Partial = {}, +): TheatreComment => ({ + theatreId: 1, + no: 1, + deleted: false, + user: { id: 1, name: 'tester' }, + content: 'テストコメント', + createdAt: '2026-01-02T03:04:05.000Z', + ...overrides, +} as TheatreComment) + +export const buildTheatreProgramme = ( + overrides: Partial = {}, +): TheatreProgramme => ({ + theatreId: 1, + position: 1, + post: buildPost (), + createdAt: '2026-01-02T03:04:05.000Z', + ...overrides, +}) + +export const buildTheatrePostSelectionWeights = ( + overrides: Partial = {}, +): TheatrePostSelectionWeights => ({ + tagPenalties: [], + lightestPosts: [], + heaviestPosts: [], + ...overrides, +}) -- 2.34.1