From d39b99f0ab0a9c768ace9ef8baca4583c39a8c1e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sat, 21 Mar 2026 04:32:07 +0900 Subject: [PATCH] #297 --- .../theatre_comments_controller.rb | 32 ++ backend/app/models/theatre_comment.rb | 4 +- backend/config/routes.rb | 2 + frontend/src/components/TopNav.tsx | 10 +- .../src/pages/theatres/TheatreDetailPage.tsx | 283 +++++++++++++++--- frontend/src/types.ts | 11 +- 6 files changed, 294 insertions(+), 48 deletions(-) create mode 100644 backend/app/controllers/theatre_comments_controller.rb diff --git a/backend/app/controllers/theatre_comments_controller.rb b/backend/app/controllers/theatre_comments_controller.rb new file mode 100644 index 0000000..0f31297 --- /dev/null +++ b/backend/app/controllers/theatre_comments_controller.rb @@ -0,0 +1,32 @@ +class TheatreCommentsController < ApplicationController + def index + no_gt = params[:no_gt].to_i + no_gt = 0 if no_gt.negative? + + comments = TheatreComment + .where(theatre_id: params[:theatre_id]) + .where('no > ?', no_gt) + .order(:no) + + render json: comments.as_json(include: { user: { only: [:id, :name] } }) + end + + def create + return head :unauthorized unless current_user + + content = params[:content] + return head :unprocessable_entity if content.blank? + + theatre = Theatre.find_by(id: params[:theatre_id]) + return head :not_found unless theatre + + comment = nil + theatre.with_lock do + no = theatre.next_comment_no + comment = TheatreComment.create!(theatre:, no:, user: current_user, content:) + theatre.update!(next_comment_no: no + 1) + end + + render json: comment, status: :created + end +end diff --git a/backend/app/models/theatre_comment.rb b/backend/app/models/theatre_comment.rb index 73883e0..e2989be 100644 --- a/backend/app/models/theatre_comment.rb +++ b/backend/app/models/theatre_comment.rb @@ -1,8 +1,8 @@ class TheatreComment < ApplicationRecord - include MyDiscard + include Discard::Model self.primary_key = :theatre_id, :no belongs_to :theatre + belongs_to :user end - diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 57eb470..b9db110 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -78,5 +78,7 @@ Rails.application.routes.draw do put :watching patch :next_post end + + resources :comments, controller: :theatre_comments, only: [:index, :create] end end diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 68e1a6e..9cac0c5 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -79,9 +79,11 @@ export default (({ user }: Props) => { { name: '上位タグ', to: '/tags/implications', visible: false }, { name: 'ニコニコ連携', to: '/tags/nico' }, { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, - // TODO: 本実装時に消す. - // { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ - // { name: '一覧', to: '/theatres' }] }, + { 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: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ { name: '検索', to: '/wiki' }, { name: '新規', to: '/wiki/new' }, @@ -92,7 +94,7 @@ export default (({ user }: Props) => { visible: wikiPageFlg }, { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, visible: wikiPageFlg }] }, - { name: 'ユーザ', to: '/users', subMenu: [ + { name: 'ユーザ', to: '/users/settings', subMenu: [ { name: '一覧', to: '/users', visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] diff --git a/frontend/src/pages/theatres/TheatreDetailPage.tsx b/frontend/src/pages/theatres/TheatreDetailPage.tsx index 61012c6..d1ecc70 100644 --- a/frontend/src/pages/theatres/TheatreDetailPage.tsx +++ b/frontend/src/pages/theatres/TheatreDetailPage.tsx @@ -3,84 +3,221 @@ import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' import PostEmbed from '@/components/PostEmbed' +import PrefetchLink from '@/components/PrefetchLink' +import TagDetailSidebar from '@/components/TagDetailSidebar' import MainArea from '@/components/layout/MainArea' +import SidebarComponent from '@/components/layout/SidebarComponent' import { SITE_TITLE } from '@/config' -import { apiGet, apiPatch, apiPut } from '@/lib/api' +import { apiGet, apiPatch, apiPost, apiPut } from '@/lib/api' import { fetchPost } from '@/lib/posts' +import { dateString } from '@/lib/utils' import type { FC } from 'react' -import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types' +import type { NiconicoMetadata, + NiconicoViewerHandle, + Post, + Theatre, + TheatreComment } from '@/types' type TheatreInfo = { hostFlg: boolean postId: number | null postStartedAt: string | null } +const INITIAL_THEATRE_INFO = { hostFlg: false, postId: null, postStartedAt: null } as const + export default (() => { const { id } = useParams () + const commentsRef = useRef (null) const embedRef = useRef (null) + const theatreInfoRef = useRef (INITIAL_THEATRE_INFO) + const videoLengthRef = useRef (0) + const lastCommentNoRef = useRef (0) + const [comments, setComments] = useState ([]) + const [content, setContent] = useState ('') const [loading, setLoading] = useState (false) + const [sending, setSending] = useState (false) const [theatre, setTheatre] = useState (null) - const [theatreInfo, setTheatreInfo] = - useState ({ hostFlg: false, postId: null, postStartedAt: null }) + const [theatreInfo, setTheatreInfo] = useState (INITIAL_THEATRE_INFO) const [post, setPost] = useState (null) - const [videoLength, setVideoLength] = useState (9_999_999_999) + const [videoLength, setVideoLength] = useState (0) + + useEffect (() => { + theatreInfoRef.current = theatreInfo + }, [theatreInfo]) + + useEffect (() => { + videoLengthRef.current = videoLength + }, [videoLength]) + + useEffect (() => { + lastCommentNoRef.current = comments.at (-1)?.no ?? 0 + }, [comments]) useEffect (() => { if (!(id)) return + let cancelled = false + + setComments ([]) + setTheatre (null) + setPost (null) + setTheatreInfo (INITIAL_THEATRE_INFO) + setVideoLength (0) + lastCommentNoRef.current = 0 + void (async () => { - setTheatre (await apiGet (`/theatres/${ id }`)) + try + { + const data = await apiGet (`/theatres/${ id }`) + if (!(cancelled)) + setTheatre (data) + } + catch (error) + { + console.error (error) + } }) () - const interval = setInterval (async () => { - if (theatreInfo.hostFlg - && theatreInfo.postStartedAt - && ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime () - > videoLength)) - setTheatreInfo ({ hostFlg: true, postId: null, postStartedAt: null }) - else - setTheatreInfo (await apiPut (`/theatres/${ id }/watching`)) - }, 1_000) + return () => { + cancelled = true + } + }, [id]) - return () => clearInterval (interval) - }, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength]) + useEffect (() => { + commentsRef.current?.scrollTo ({ + top: commentsRef.current.scrollHeight, + behavior: 'smooth' }) + }, [commentsRef]) useEffect (() => { - if (!(theatreInfo.hostFlg) || loading) + if (!(id)) return - if (theatreInfo.postId == null) + let cancelled = false + let running = false + + const tick = async () => { + if (running) + return + + running = true + + try + { + const newComments = await apiGet ( + `/theatres/${ id }/comments`, + { params: { no_gt: lastCommentNoRef.current } }) + + if (!(cancelled) && newComments.length > 0) + { + lastCommentNoRef.current = newComments[newComments.length - 1].no + setComments (prev => [...prev, ...newComments]) + } + + const currentInfo = theatreInfoRef.current + const ended = + currentInfo.hostFlg + && currentInfo.postStartedAt + && ((Date.now () - (new Date (currentInfo.postStartedAt)).getTime ()) + > videoLengthRef.current + 3_000) + + if (ended) + { + if (!(cancelled)) + setTheatreInfo (prev => ({ ...prev, postId: null, postStartedAt: null })) + + return + } + + const nextInfo = await apiPut (`/theatres/${ id }/watching`) + if (!(cancelled)) + setTheatreInfo (nextInfo) + } + catch (error) + { + console.error (error) + } + finally { - void (async () => { - setLoading (true) - await apiPatch (`/theatres/${ id }/next_post`) + running = false + } + } + + tick () + const interval = setInterval (() => tick (), 1_000) + + return () => { + cancelled = true + clearInterval (interval) + } + }, [id]) + + useEffect (() => { + if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) + return + + let cancelled = false + + void (async () => { + setLoading (true) + + try + { + await apiPatch (`/theatres/${ id }/next_post`) + } + catch (error) + { + console.error (error) + } + finally + { + if (!(cancelled)) setLoading (false) - }) () - return } + }) () + + return () => { + cancelled = true + } }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) useEffect (() => { if (theatreInfo.postId == null) return + let cancelled = false + void (async () => { - setPost (await fetchPost (String (theatreInfo.postId))) + try + { + const nextPost = await fetchPost (String (theatreInfo.postId)) + if (!(cancelled)) + setPost (nextPost) + } + catch (error) + { + console.error (error) + } }) () + + return () => { + cancelled = true + } }, [theatreInfo.postId, theatreInfo.postStartedAt]) const syncPlayback = (meta: NiconicoMetadata) => { if (!(theatreInfo.postStartedAt)) return - const targetTime = - ((new Date).getTime () - (new Date (theatreInfo.postStartedAt)).getTime ()) + const targetTime = Math.min ( + Math.max (0, Date.now () - (new Date (theatreInfo.postStartedAt)).getTime ()), + videoLength) const drift = Math.abs (meta.currentTime - targetTime) @@ -89,7 +226,7 @@ export default (() => { } return ( - +
{theatre && ( @@ -99,16 +236,82 @@ export default (() => { )} - {post && ( - { - embedRef.current?.play () - setVideoLength (info.lengthInSeconds * 1_000) - }} - onMetadataChange={meta => { - syncPlayback (meta) - }}/>)} - ) +
+ +
+ + + {post ? ( + <> + { + embedRef.current?.play () + setVideoLength (info.lengthInSeconds * 1_000) + }} + onMetadataChange={syncPlayback}/> +
+ <>再生中: + + {post.title || post.url} + +
+ ) : 'Loading...'} +
+ + + {comments.length > 0 && ( +
{ + e.preventDefault () + + if (!(content)) + return + + try + { + setSending (true) + await apiPost (`/theatres/${ id }/comments`, { content }) + setContent ('') + commentsRef.current?.scrollTo ({ + top: commentsRef.current.scrollHeight, + behavior: 'smooth' }) + } + finally + { + setSending (false) + } + }}> +
+ {comments.map (comment => ( +
+
+ {comment.content} +
+
+ by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'} +
+
+ {dateString (comment.createdAt)} +
+
))} +
+ setContent (e.target.value)} + disabled={sending}/> +
)} +
+ +
+ +
+
) }) satisfies FC diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 6538121..d95eb27 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,7 +29,7 @@ export type FetchPostsParams = { export type Menu = MenuItem[] export type MenuItem = { - name: string + name: ReactNode to: string base?: string subMenu: SubMenuItem[] } @@ -93,7 +93,7 @@ export type PostTagChange = { export type SubMenuItem = | { component: ReactNode visible: boolean } - | { name: string + | { name: ReactNode to: string visible?: boolean } @@ -115,6 +115,13 @@ export type Theatre = { createdAt: string updatedAt: string } +export type TheatreComment = { + theatreId: number, + no: number, + user: { id: number, name: string } | null + content: string + createdAt: string } + export type User = { id: number name: string | null