コミットを比較
5 コミット
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| 63c1dd197c | |||
| 76f8e6875e | |||
| 57e82b6ffd | |||
| 505961898e | |||
| e133c684c8 |
@@ -6,7 +6,7 @@ class TheatreCommentsController < ApplicationController
|
|||||||
comments = TheatreComment
|
comments = TheatreComment
|
||||||
.where(theatre_id: params[:theatre_id])
|
.where(theatre_id: params[:theatre_id])
|
||||||
.where('no > ?', no_gt)
|
.where('no > ?', no_gt)
|
||||||
.order(:no)
|
.order(no: :desc)
|
||||||
|
|
||||||
render json: comments.as_json(include: { user: { only: [:id, :name] } })
|
render json: comments.as_json(include: { user: { only: [:id, :name] } })
|
||||||
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
|
||||||
|
|||||||
@@ -51,14 +51,14 @@ RSpec.describe 'TheatreComments', type: :request do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'theatre_id で絞り込み、no_gt より大きいものを no 昇順で返す' do
|
it 'theatre_id で絞り込み、no_gt より大きいものを no 降順で返す' do
|
||||||
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
|
get "/theatres/#{theatre.id}/comments", params: { no_gt: 1 }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([2, 3])
|
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2])
|
||||||
expect(response.parsed_body.map { |row| row['content'] }).to eq([
|
expect(response.parsed_body.map { |row| row['content'] }).to eq([
|
||||||
'second comment',
|
'third comment',
|
||||||
'third comment'
|
'second comment'
|
||||||
])
|
])
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -68,8 +68,8 @@ RSpec.describe 'TheatreComments', type: :request do
|
|||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
|
|
||||||
expect(response.parsed_body.first['user']).to eq({
|
expect(response.parsed_body.first['user']).to eq({
|
||||||
'id' => bob.id,
|
'id' => alice.id,
|
||||||
'name' => 'Bob'
|
'name' => 'Alice'
|
||||||
})
|
})
|
||||||
expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name')
|
expect(response.parsed_body.first['user'].keys).to contain_exactly('id', 'name')
|
||||||
end
|
end
|
||||||
@@ -78,7 +78,7 @@ RSpec.describe 'TheatreComments', type: :request do
|
|||||||
get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 }
|
get "/theatres/#{theatre.id}/comments", params: { no_gt: -100 }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.parsed_body.map { |row| row['no'] }).to eq([1, 2, 3])
|
expect(response.parsed_body.map { |row| row['no'] }).to eq([3, 2, 1])
|
||||||
end
|
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 ()
|
||||||
|
|||||||
@@ -211,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>)))}
|
||||||
@@ -277,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,6 +2,7 @@ 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 PrefetchLink from '@/components/PrefetchLink'
|
||||||
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
import TagDetailSidebar from '@/components/TagDetailSidebar'
|
||||||
@@ -23,9 +24,14 @@ import type { NiconicoMetadata,
|
|||||||
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 } as const
|
const INITIAL_THEATRE_INFO =
|
||||||
|
{ hostFlg: false,
|
||||||
|
postId: null,
|
||||||
|
postStartedAt: null,
|
||||||
|
watchingUsers: [] as { id: number; name: string }[] } as const
|
||||||
|
|
||||||
|
|
||||||
export default (() => {
|
export default (() => {
|
||||||
@@ -41,6 +47,7 @@ export default (() => {
|
|||||||
const [content, setContent] = useState ('')
|
const [content, setContent] = useState ('')
|
||||||
const [loading, setLoading] = useState (false)
|
const [loading, setLoading] = useState (false)
|
||||||
const [sending, setSending] = 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> (INITIAL_THEATRE_INFO)
|
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||||||
const [post, setPost] = useState<Post | null> (null)
|
const [post, setPost] = useState<Post | null> (null)
|
||||||
@@ -55,7 +62,7 @@ export default (() => {
|
|||||||
}, [videoLength])
|
}, [videoLength])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
lastCommentNoRef.current = comments.at (-1)?.no ?? 0
|
lastCommentNoRef.current = comments[0]?.no ?? 0
|
||||||
}, [comments])
|
}, [comments])
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
@@ -80,7 +87,7 @@ export default (() => {
|
|||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
console.error (error)
|
setStatus ((error as any)?.response.status ?? 200)
|
||||||
}
|
}
|
||||||
}) ()
|
}) ()
|
||||||
|
|
||||||
@@ -89,12 +96,6 @@ export default (() => {
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
useEffect (() => {
|
|
||||||
commentsRef.current?.scrollTo ({
|
|
||||||
top: commentsRef.current.scrollHeight,
|
|
||||||
behavior: 'smooth' })
|
|
||||||
}, [commentsRef])
|
|
||||||
|
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
if (!(id))
|
if (!(id))
|
||||||
return
|
return
|
||||||
@@ -117,7 +118,7 @@ export default (() => {
|
|||||||
if (!(cancelled) && newComments.length > 0)
|
if (!(cancelled) && newComments.length > 0)
|
||||||
{
|
{
|
||||||
lastCommentNoRef.current = newComments[newComments.length - 1].no
|
lastCommentNoRef.current = newComments[newComments.length - 1].no
|
||||||
setComments (prev => [...prev, ...newComments])
|
setComments (prev => [...newComments, ...prev])
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentInfo = theatreInfoRef.current
|
const currentInfo = theatreInfoRef.current
|
||||||
@@ -227,6 +228,9 @@ export default (() => {
|
|||||||
embedRef.current?.seek (targetTime)
|
embedRef.current?.seek (targetTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (status >= 400)
|
||||||
|
return <ErrorScreen status={status}/>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md:flex md:flex-1">
|
<div className="md:flex md:flex-1">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -239,7 +243,7 @@ export default (() => {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<TagDetailSidebar post={post ?? null}/>
|
{post && <TagDetailSidebar post={post}/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MainArea>
|
<MainArea>
|
||||||
@@ -265,7 +269,7 @@ export default (() => {
|
|||||||
|
|
||||||
<SidebarComponent>
|
<SidebarComponent>
|
||||||
<form
|
<form
|
||||||
className="w-full h-5/6"
|
className="w-auto h-auto border border-black dark:border-white rounded mx-2"
|
||||||
onSubmit={async e => {
|
onSubmit={async e => {
|
||||||
e.preventDefault ()
|
e.preventDefault ()
|
||||||
|
|
||||||
@@ -277,43 +281,61 @@ export default (() => {
|
|||||||
setSending (true)
|
setSending (true)
|
||||||
await apiPost (`/theatres/${ id }/comments`, { content })
|
await apiPost (`/theatres/${ id }/comments`, { content })
|
||||||
setContent ('')
|
setContent ('')
|
||||||
commentsRef.current?.scrollTo ({
|
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
|
||||||
top: commentsRef.current.scrollHeight,
|
|
||||||
behavior: 'smooth' })
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
setSending (false)
|
setSending (false)
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
|
<input
|
||||||
|
className="w-full p-2 border rounded"
|
||||||
|
type="text"
|
||||||
|
placeholder="ここにコメントを入力"
|
||||||
|
value={content}
|
||||||
|
onChange={e => setContent (e.target.value)}
|
||||||
|
disabled={sending}/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={commentsRef}
|
ref={commentsRef}
|
||||||
className="overflow-x-hidden overflow-y-scroll text-wrap
|
className="overflow-x-hidden overflow-y-scroll text-wrap w-full
|
||||||
border border-black dark:border-white w-full h-[80vh]">
|
h-[32vh] md:h-[64vh] border rounded">
|
||||||
{comments.map (comment => (
|
{comments.map (comment => (
|
||||||
<div key={comment.no} className="p-2">
|
<div key={comment.no} className="p-2">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{comment.content}
|
{comment.content}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-sm text-right">
|
<div className="w-full text-sm text-right">
|
||||||
by {comment.user ? (comment.user.name || '名もなきニジラー') : '運営'}
|
by {comment.user
|
||||||
|
? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`)
|
||||||
|
: '運営'}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-sm text-right">
|
<div className="w-full text-sm text-right">
|
||||||
{dateString (comment.createdAt)}
|
{dateString (comment.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>))}
|
</div>))}
|
||||||
</div>
|
</div>
|
||||||
<input
|
|
||||||
className="w-full p-2 border border-black dark:border-white"
|
|
||||||
type="text"
|
|
||||||
value={content}
|
|
||||||
onChange={e => setContent (e.target.value)}
|
|
||||||
disabled={sending}/>
|
|
||||||
</form>
|
</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>
|
</SidebarComponent>
|
||||||
|
|
||||||
<div className="md:hidden">
|
<div className="md:hidden">
|
||||||
<TagDetailSidebar post={post ?? null}/>
|
{post && <TagDetailSidebar post={post} sp/>}
|
||||||
</div>
|
</div>
|
||||||
</div>)
|
</div>)
|
||||||
}) satisfies FC
|
}) satisfies FC
|
||||||
|
|||||||
新しい課題から参照
ユーザをブロックする