feature/297 into main
| @@ -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: :desc) | |||||
| 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 | |||||
| @@ -31,7 +31,9 @@ class TheatresController < ApplicationController | |||||
| post_started_at = theatre.current_post_started_at | post_started_at = theatre.current_post_started_at | ||||
| end | end | ||||
| render json: { host_flg:, post_id:, post_started_at: } | |||||
| render json: { | |||||
| host_flg:, post_id:, post_started_at:, | |||||
| watching_users: theatre.watching_users.as_json(only: [:id, :name]) } | |||||
| end | end | ||||
| def next_post | def next_post | ||||
| @@ -1,8 +1,8 @@ | |||||
| class TheatreComment < ApplicationRecord | class TheatreComment < ApplicationRecord | ||||
| include MyDiscard | |||||
| include Discard::Model | |||||
| self.primary_key = :theatre_id, :no | self.primary_key = :theatre_id, :no | ||||
| belongs_to :theatre | belongs_to :theatre | ||||
| belongs_to :user | |||||
| end | end | ||||
| @@ -78,5 +78,7 @@ Rails.application.routes.draw do | |||||
| put :watching | put :watching | ||||
| patch :next_post | patch :next_post | ||||
| end | end | ||||
| resources :comments, controller: :theatre_comments, only: [:index, :create] | |||||
| end | end | ||||
| end | end | ||||
| @@ -0,0 +1,8 @@ | |||||
| FactoryBot.define do | |||||
| factory :theatre_comment do | |||||
| association :theatre | |||||
| association :user | |||||
| sequence (:no) { |n| n } | |||||
| content { 'test comment' } | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,11 @@ | |||||
| FactoryBot.define do | |||||
| factory :theatre do | |||||
| name { 'Test Theatre' } | |||||
| kind { 1 } | |||||
| opens_at { Time.current } | |||||
| closes_at { 1.day.from_now } | |||||
| next_comment_no { 1 } | |||||
| association :created_by_user, factory: :user | |||||
| end | |||||
| end | |||||
| @@ -0,0 +1,150 @@ | |||||
| require 'rails_helper' | |||||
| RSpec.describe 'TheatreComments', type: :request do | |||||
| def sign_in_as(user) | |||||
| allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user) | |||||
| end | |||||
| describe 'GET /theatres/:theatre_id/comments' do | |||||
| let(:theatre) { create(:theatre) } | |||||
| let(:other_theatre) { create(:theatre) } | |||||
| let(:alice) { create(:user, name: 'Alice') } | |||||
| let(:bob) { create(:user, name: 'Bob') } | |||||
| let!(:comment_3) do | |||||
| create( | |||||
| :theatre_comment, | |||||
| theatre: theatre, | |||||
| no: 3, | |||||
| user: alice, | |||||
| content: 'third comment' | |||||
| ) | |||||
| end | |||||
| let!(:comment_1) do | |||||
| create( | |||||
| :theatre_comment, | |||||
| theatre: theatre, | |||||
| no: 1, | |||||
| user: alice, | |||||
| content: 'first comment' | |||||
| ) | |||||
| end | |||||
| let!(:comment_2) do | |||||
| create( | |||||
| :theatre_comment, | |||||
| theatre: theatre, | |||||
| no: 2, | |||||
| user: bob, | |||||
| content: 'second comment' | |||||
| ) | |||||
| end | |||||
| let!(:other_comment) do | |||||
| create( | |||||
| :theatre_comment, | |||||
| theatre: other_theatre, | |||||
| no: 1, | |||||
| user: bob, | |||||
| content: 'other theatre comment' | |||||
| ) | |||||
| end | |||||
| it 'theatre_id で絞り込み、no_gt より大きいものを no 降順で返す' do | |||||
| get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2]) | |||||
| expect(response.parsed_body.map { |row| row['content'] }).to eq([ | |||||
| 'third comment', | |||||
| 'second comment' | |||||
| ]) | |||||
| end | |||||
| it 'user は id と name だけを含む' do | |||||
| get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(response.parsed_body.first['user']).to eq({ | |||||
| 'id' => alice.id, | |||||
| 'name' => 'Alice' | |||||
| }) | |||||
| expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name') | |||||
| end | |||||
| it 'no_gt が負数なら 0 として扱う' do | |||||
| get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 } | |||||
| expect(response).to have_http_status(:ok) | |||||
| expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1]) | |||||
| end | |||||
| end | |||||
| describe 'POST /theatres/:theatre_id/comments' do | |||||
| let(:user) { create(:user, name: 'Alice') } | |||||
| let(:theatre) { create(:theatre, next_comment_no: 2) } | |||||
| before do | |||||
| create( | |||||
| :theatre_comment, | |||||
| theatre: theatre, | |||||
| no: 1, | |||||
| user: user, | |||||
| content: 'existing comment' | |||||
| ) | |||||
| end | |||||
| it '未ログインなら 401 を返す' do | |||||
| expect { | |||||
| post "/theatres/#{theatre.id}/comments", params: { content: 'hello' } | |||||
| }.not_to change(TheatreComment, :count) | |||||
| expect(response).to have_http_status(:unauthorized) | |||||
| end | |||||
| it 'content が blank なら 422 を返す' do | |||||
| sign_in_as(user) | |||||
| expect { | |||||
| post "/theatres/#{theatre.id}/comments", params: { content: ' ' } | |||||
| }.not_to change(TheatreComment, :count) | |||||
| expect(response).to have_http_status(:unprocessable_entity) | |||||
| end | |||||
| it 'theatre が存在しなければ 404 を返す' do | |||||
| sign_in_as(user) | |||||
| expect { | |||||
| post '/theatres/999999/comments', params: { content: 'hello' } | |||||
| }.not_to change(TheatreComment, :count) | |||||
| expect(response).to have_http_status(:not_found) | |||||
| end | |||||
| it 'コメントを作成し、user を紐づけ、next_comment_no を進める' do | |||||
| sign_in_as(user) | |||||
| expect { | |||||
| post "/theatres/#{theatre.id}/comments", params: { content: 'new comment' } | |||||
| }.to change(TheatreComment, :count).by(1) | |||||
| expect(response).to have_http_status(:created) | |||||
| comment = TheatreComment.find_by!(theatre: theatre, no: 2) | |||||
| expect(comment.user).to eq(user) | |||||
| expect(comment.content).to eq('new comment') | |||||
| expect(theatre.reload.next_comment_no).to eq(3) | |||||
| expect(response.parsed_body.slice('theatre_id', 'no', 'user_id', 'content')).to eq({ | |||||
| 'theatre_id' => theatre.id, | |||||
| 'no' => 2, | |||||
| 'user_id' => user.id, | |||||
| 'content' => 'new comment' | |||||
| }) | |||||
| end | |||||
| end | |||||
| end | |||||
| @@ -117,11 +117,18 @@ RSpec.describe 'Theatres API', type: :request do | |||||
| expect(theatre.host_user_id).to eq(member.id) | expect(theatre.host_user_id).to eq(member.id) | ||||
| expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) | expect(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) | ||||
| expect(json).to eq( | |||||
| expect(json).to include( | |||||
| 'host_flg' => true, | 'host_flg' => true, | ||||
| 'post_id' => nil, | 'post_id' => nil, | ||||
| 'post_started_at' => nil | 'post_started_at' => nil | ||||
| ) | ) | ||||
| expect(json.fetch('watching_users')).to contain_exactly( | |||||
| { | |||||
| 'id' => member.id, | |||||
| 'name' => 'member user' | |||||
| } | |||||
| ) | |||||
| end | end | ||||
| end | end | ||||
| @@ -167,11 +174,22 @@ RSpec.describe 'Theatres API', type: :request do | |||||
| expect(response).to have_http_status(:ok) | expect(response).to have_http_status(:ok) | ||||
| expect(theatre.reload.host_user_id).to eq(other_user.id) | expect(theatre.reload.host_user_id).to eq(other_user.id) | ||||
| expect(json).to eq( | |||||
| expect(json).to include( | |||||
| 'host_flg' => false, | 'host_flg' => false, | ||||
| 'post_id' => nil, | 'post_id' => nil, | ||||
| 'post_started_at' => nil | 'post_started_at' => nil | ||||
| ) | ) | ||||
| expect(json.fetch('watching_users')).to contain_exactly( | |||||
| { | |||||
| 'id' => member.id, | |||||
| 'name' => 'member user' | |||||
| }, | |||||
| { | |||||
| 'id' => other_user.id, | |||||
| 'name' => 'other user' | |||||
| } | |||||
| ) | |||||
| end | end | ||||
| end | end | ||||
| @@ -71,7 +71,8 @@ export default forwardRef<HTMLAnchorElement, Props> (({ | |||||
| || ev.metaKey | || ev.metaKey | ||||
| || ev.ctrlKey | || ev.ctrlKey | ||||
| || ev.shiftKey | || ev.shiftKey | ||||
| || ev.altKey) | |||||
| || ev.altKey | |||||
| || (rest.target && rest.target !== '_self')) | |||||
| return | return | ||||
| ev.preventDefault () | ev.preventDefault () | ||||
| @@ -79,9 +79,11 @@ export default (({ user }: Props) => { | |||||
| { name: '上位タグ', to: '/tags/implications', visible: false }, | { name: '上位タグ', to: '/tags/implications', visible: false }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }] }, | { 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: 'Wiki', to: '/wiki/ヘルプ:ホーム', base: '/wiki', subMenu: [ | ||||
| { name: '検索', to: '/wiki' }, | { name: '検索', to: '/wiki' }, | ||||
| { name: '新規', to: '/wiki/new' }, | { name: '新規', to: '/wiki/new' }, | ||||
| @@ -92,7 +94,7 @@ export default (({ user }: Props) => { | |||||
| visible: wikiPageFlg }, | visible: wikiPageFlg }, | ||||
| { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, | { name: '履歴', to: `/wiki/changes?id=${ wikiId }`, visible: wikiPageFlg }, | ||||
| { name: '編輯', to: `/wiki/${ wikiId || wikiTitle }/edit`, 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', visible: false }, | ||||
| { name: 'お前', to: `/users/${ user?.id }`, visible: false }, | { name: 'お前', to: `/users/${ user?.id }`, visible: false }, | ||||
| { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] | { name: '設定', to: '/users/settings', visible: Boolean (user) }] }] | ||||
| @@ -209,6 +211,7 @@ export default (({ user }: Props) => { | |||||
| <PrefetchLink | <PrefetchLink | ||||
| key={`l-${ i }`} | key={`l-${ i }`} | ||||
| to={item.to} | to={item.to} | ||||
| target={item.to.slice (0, 2) === '//' ? '_blank' : undefined} | |||||
| className="h-full flex items-center px-3"> | className="h-full flex items-center px-3"> | ||||
| {item.name} | {item.name} | ||||
| </PrefetchLink>)))} | </PrefetchLink>)))} | ||||
| @@ -275,6 +278,9 @@ export default (({ user }: Props) => { | |||||
| <PrefetchLink | <PrefetchLink | ||||
| key={`sp-l-${ i }-${ j }`} | key={`sp-l-${ i }-${ j }`} | ||||
| to={subItem.to} | to={subItem.to} | ||||
| target={subItem.to.slice (0, 2) === '//' | |||||
| ? '_blank' | |||||
| : undefined} | |||||
| className="w-full min-h-[36px] flex items-center pl-12"> | className="w-full min-h-[36px] flex items-center pl-12"> | ||||
| {subItem.name} | {subItem.name} | ||||
| </PrefetchLink>)))} | </PrefetchLink>)))} | ||||
| @@ -2,85 +2,225 @@ import { useEffect, useRef, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useParams } from 'react-router-dom' | import { useParams } from 'react-router-dom' | ||||
| import ErrorScreen from '@/components/ErrorScreen' | |||||
| import PostEmbed from '@/components/PostEmbed' | import PostEmbed from '@/components/PostEmbed' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TagDetailSidebar from '@/components/TagDetailSidebar' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | |||||
| import { SITE_TITLE } from '@/config' | 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 { fetchPost } from '@/lib/posts' | ||||
| import { dateString } from '@/lib/utils' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { NiconicoMetadata, NiconicoViewerHandle, Post, Theatre } from '@/types' | |||||
| import type { NiconicoMetadata, | |||||
| NiconicoViewerHandle, | |||||
| Post, | |||||
| Theatre, | |||||
| TheatreComment } from '@/types' | |||||
| type TheatreInfo = { | type TheatreInfo = { | ||||
| hostFlg: boolean | hostFlg: boolean | ||||
| postId: number | null | postId: number | null | ||||
| postStartedAt: string | null } | |||||
| postStartedAt: string | null | |||||
| watchingUsers: { id: number; name: string }[] } | |||||
| const INITIAL_THEATRE_INFO = | |||||
| { hostFlg: false, | |||||
| postId: null, | |||||
| postStartedAt: null, | |||||
| watchingUsers: [] as { id: number; name: string }[] } as const | |||||
| export default (() => { | export default (() => { | ||||
| const { id } = useParams () | const { id } = useParams () | ||||
| const commentsRef = useRef<HTMLDivElement> (null) | |||||
| const embedRef = useRef<NiconicoViewerHandle> (null) | const embedRef = useRef<NiconicoViewerHandle> (null) | ||||
| const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO) | |||||
| const videoLengthRef = useRef (0) | |||||
| const lastCommentNoRef = useRef (0) | |||||
| const [comments, setComments] = useState<TheatreComment[]> ([]) | |||||
| const [content, setContent] = useState ('') | |||||
| const [loading, setLoading] = useState (false) | const [loading, setLoading] = useState (false) | ||||
| const [sending, setSending] = useState (false) | |||||
| const [status, setStatus] = useState (200) | |||||
| const [theatre, setTheatre] = useState<Theatre | null> (null) | const [theatre, setTheatre] = useState<Theatre | null> (null) | ||||
| const [theatreInfo, setTheatreInfo] = | |||||
| useState<TheatreInfo> ({ hostFlg: false, postId: null, postStartedAt: null }) | |||||
| const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) | |||||
| const [post, setPost] = useState<Post | null> (null) | const [post, setPost] = useState<Post | null> (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[0]?.no ?? 0 | |||||
| }, [comments]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(id)) | if (!(id)) | ||||
| return | return | ||||
| let cancelled = false | |||||
| setComments ([]) | |||||
| setTheatre (null) | |||||
| setPost (null) | |||||
| setTheatreInfo (INITIAL_THEATRE_INFO) | |||||
| setVideoLength (0) | |||||
| lastCommentNoRef.current = 0 | |||||
| void (async () => { | void (async () => { | ||||
| setTheatre (await apiGet<Theatre> (`/theatres/${ id }`)) | |||||
| try | |||||
| { | |||||
| const data = await apiGet<Theatre> (`/theatres/${ id }`) | |||||
| if (!(cancelled)) | |||||
| setTheatre (data) | |||||
| } | |||||
| catch (error) | |||||
| { | |||||
| setStatus ((error as any)?.response.status ?? 200) | |||||
| } | |||||
| }) () | }) () | ||||
| 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<TheatreInfo> (`/theatres/${ id }/watching`)) | |||||
| }, 1_000) | |||||
| return () => { | |||||
| cancelled = true | |||||
| } | |||||
| }, [id]) | |||||
| return () => clearInterval (interval) | |||||
| }, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength]) | |||||
| useEffect (() => { | |||||
| if (!(id)) | |||||
| return | |||||
| let cancelled = false | |||||
| let running = false | |||||
| const tick = async () => { | |||||
| if (running) | |||||
| return | |||||
| running = true | |||||
| try | |||||
| { | |||||
| const newComments = await apiGet<TheatreComment[]> ( | |||||
| `/theatres/${ id }/comments`, | |||||
| { params: { no_gt: lastCommentNoRef.current } }) | |||||
| if (!(cancelled) && newComments.length > 0) | |||||
| { | |||||
| lastCommentNoRef.current = newComments[newComments.length - 1].no | |||||
| setComments (prev => [...newComments, ...prev]) | |||||
| } | |||||
| 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<TheatreInfo> (`/theatres/${ id }/watching`) | |||||
| if (!(cancelled)) | |||||
| setTheatreInfo (nextInfo) | |||||
| } | |||||
| catch (error) | |||||
| { | |||||
| console.error (error) | |||||
| } | |||||
| finally | |||||
| { | |||||
| running = false | |||||
| } | |||||
| } | |||||
| tick () | |||||
| const interval = setInterval (() => tick (), 1_500) | |||||
| return () => { | |||||
| cancelled = true | |||||
| clearInterval (interval) | |||||
| } | |||||
| }, [id]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| if (!(theatreInfo.hostFlg) || loading) | |||||
| if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) | |||||
| return | return | ||||
| if (theatreInfo.postId == null) | |||||
| let cancelled = false | |||||
| void (async () => { | |||||
| setLoading (true) | |||||
| try | |||||
| { | { | ||||
| void (async () => { | |||||
| setLoading (true) | |||||
| await apiPatch<void> (`/theatres/${ id }/next_post`) | |||||
| await apiPatch<void> (`/theatres/${ id }/next_post`) | |||||
| } | |||||
| catch (error) | |||||
| { | |||||
| console.error (error) | |||||
| } | |||||
| finally | |||||
| { | |||||
| if (!(cancelled)) | |||||
| setLoading (false) | setLoading (false) | ||||
| }) () | |||||
| return | |||||
| } | } | ||||
| }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) | |||||
| }) () | |||||
| return () => { | |||||
| cancelled = true | |||||
| } | |||||
| }, [id, theatreInfo.hostFlg, theatreInfo.postId]) | |||||
| useEffect (() => { | useEffect (() => { | ||||
| setVideoLength (0) | |||||
| if (theatreInfo.postId == null) | if (theatreInfo.postId == null) | ||||
| return | return | ||||
| let cancelled = false | |||||
| void (async () => { | 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) | |||||
| } | |||||
| }) () | }) () | ||||
| }, [theatreInfo.postId, theatreInfo.postStartedAt]) | |||||
| return () => { | |||||
| cancelled = true | |||||
| } | |||||
| }, [theatreInfo.postId]) | |||||
| const syncPlayback = (meta: NiconicoMetadata) => { | const syncPlayback = (meta: NiconicoMetadata) => { | ||||
| if (!(theatreInfo.postStartedAt)) | if (!(theatreInfo.postStartedAt)) | ||||
| return | 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) | const drift = Math.abs (meta.currentTime - targetTime) | ||||
| @@ -88,8 +228,11 @@ export default (() => { | |||||
| embedRef.current?.seek (targetTime) | embedRef.current?.seek (targetTime) | ||||
| } | } | ||||
| if (status >= 400) | |||||
| return <ErrorScreen status={status}/> | |||||
| return ( | return ( | ||||
| <MainArea> | |||||
| <div className="md:flex md:flex-1"> | |||||
| <Helmet> | <Helmet> | ||||
| {theatre && ( | {theatre && ( | ||||
| <title> | <title> | ||||
| @@ -99,16 +242,100 @@ export default (() => { | |||||
| </title>)} | </title>)} | ||||
| </Helmet> | </Helmet> | ||||
| {post && ( | |||||
| <PostEmbed | |||||
| ref={embedRef} | |||||
| post={post} | |||||
| onLoadComplete={info => { | |||||
| embedRef.current?.play () | |||||
| setVideoLength (info.lengthInSeconds * 1_000) | |||||
| }} | |||||
| onMetadataChange={meta => { | |||||
| syncPlayback (meta) | |||||
| }}/>)} | |||||
| </MainArea>) | |||||
| <div className="hidden md:block"> | |||||
| {post && <TagDetailSidebar post={post}/>} | |||||
| </div> | |||||
| <MainArea> | |||||
| {post ? ( | |||||
| <> | |||||
| <PostEmbed | |||||
| key={post.id} | |||||
| ref={embedRef} | |||||
| post={post} | |||||
| onLoadComplete={info => { | |||||
| embedRef.current?.play () | |||||
| setVideoLength (info.lengthInSeconds * 1_000) | |||||
| }} | |||||
| onMetadataChange={syncPlayback}/> | |||||
| <div className="m-2"> | |||||
| <>再生中:</> | |||||
| <PrefetchLink to={`/posts/${ post.id }`} className="font-bold"> | |||||
| {post.title || post.url} | |||||
| </PrefetchLink> | |||||
| </div> | |||||
| </>) : 'Loading...'} | |||||
| </MainArea> | |||||
| <SidebarComponent> | |||||
| <form | |||||
| className="w-auto h-auto border border-black dark:border-white rounded mx-2" | |||||
| onSubmit={async e => { | |||||
| e.preventDefault () | |||||
| if (!(content)) | |||||
| return | |||||
| try | |||||
| { | |||||
| setSending (true) | |||||
| await apiPost (`/theatres/${ id }/comments`, { content }) | |||||
| setContent ('') | |||||
| commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' }) | |||||
| } | |||||
| finally | |||||
| { | |||||
| setSending (false) | |||||
| } | |||||
| }}> | |||||
| <input | |||||
| className="w-full p-2 border rounded" | |||||
| type="text" | |||||
| placeholder="ここにコメントを入力" | |||||
| value={content} | |||||
| onChange={e => setContent (e.target.value)} | |||||
| disabled={sending}/> | |||||
| <div | |||||
| ref={commentsRef} | |||||
| 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>))} | |||||
| </div> | |||||
| </form> | |||||
| <div className="w-auto h-auto border border-black dark:border-white rounded mx-2 mt-4"> | |||||
| <div className="p-2"> | |||||
| 現在の同接数:{theatreInfo.watchingUsers.length} | |||||
| </div> | |||||
| <div className="overflow-x-hidden overflow-y-scroll text-wrap w-full h-32 | |||||
| border rounded"> | |||||
| <ul className="list-inside list-disc"> | |||||
| {theatreInfo.watchingUsers.map (user => ( | |||||
| <li key={user.id} className="px-4 py-1 text-sm"> | |||||
| {user.name || `名もなきニジラー(#${ user.id })`} | |||||
| </li>))} | |||||
| </ul> | |||||
| </div> | |||||
| </div> | |||||
| </SidebarComponent> | |||||
| <div className="md:hidden"> | |||||
| {post && <TagDetailSidebar post={post} sp/>} | |||||
| </div> | |||||
| </div>) | |||||
| }) satisfies FC | }) satisfies FC | ||||
| @@ -52,7 +52,7 @@ export type FetchTagsParams = { | |||||
| export type Menu = MenuItem[] | export type Menu = MenuItem[] | ||||
| export type MenuItem = { | export type MenuItem = { | ||||
| name: string | |||||
| name: ReactNode | |||||
| to: string | to: string | ||||
| base?: string | base?: string | ||||
| subMenu: SubMenuItem[] } | subMenu: SubMenuItem[] } | ||||
| @@ -117,7 +117,7 @@ export type PostTagChange = { | |||||
| export type SubMenuItem = | export type SubMenuItem = | ||||
| | { component: ReactNode | | { component: ReactNode | ||||
| visible: boolean } | visible: boolean } | ||||
| | { name: string | |||||
| | { name: ReactNode | |||||
| to: string | to: string | ||||
| visible?: boolean } | visible?: boolean } | ||||
| @@ -141,6 +141,13 @@ export type Theatre = { | |||||
| createdAt: string | createdAt: string | ||||
| updatedAt: string } | updatedAt: string } | ||||
| export type TheatreComment = { | |||||
| theatreId: number, | |||||
| no: number, | |||||
| user: { id: number, name: string } | null | |||||
| content: string | |||||
| createdAt: string } | |||||
| export type User = { | export type User = { | ||||
| id: number | id: number | ||||
| name: string | null | name: string | null | ||||