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 | |||
| 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 | |||
| def next_post | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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(watch.expires_at).to be_within(1.second).of(30.seconds.from_now) | |||
| expect(json).to eq( | |||
| expect(json).to include( | |||
| 'host_flg' => true, | |||
| 'post_id' => nil, | |||
| 'post_started_at' => nil | |||
| ) | |||
| expect(json.fetch('watching_users')).to contain_exactly( | |||
| { | |||
| 'id' => member.id, | |||
| 'name' => 'member user' | |||
| } | |||
| ) | |||
| end | |||
| end | |||
| @@ -167,11 +174,22 @@ RSpec.describe 'Theatres API', type: :request do | |||
| expect(response).to have_http_status(:ok) | |||
| expect(theatre.reload.host_user_id).to eq(other_user.id) | |||
| expect(json).to eq( | |||
| expect(json).to include( | |||
| 'host_flg' => false, | |||
| 'post_id' => 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 | |||
| @@ -71,7 +71,8 @@ export default forwardRef<HTMLAnchorElement, Props> (({ | |||
| || ev.metaKey | |||
| || ev.ctrlKey | |||
| || ev.shiftKey | |||
| || ev.altKey) | |||
| || ev.altKey | |||
| || (rest.target && rest.target !== '_self')) | |||
| return | |||
| ev.preventDefault () | |||
| @@ -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) }] }] | |||
| @@ -209,6 +211,7 @@ export default (({ user }: Props) => { | |||
| <PrefetchLink | |||
| key={`l-${ i }`} | |||
| to={item.to} | |||
| target={item.to.slice (0, 2) === '//' ? '_blank' : undefined} | |||
| className="h-full flex items-center px-3"> | |||
| {item.name} | |||
| </PrefetchLink>)))} | |||
| @@ -275,6 +278,9 @@ export default (({ user }: Props) => { | |||
| <PrefetchLink | |||
| key={`sp-l-${ i }-${ j }`} | |||
| to={subItem.to} | |||
| target={subItem.to.slice (0, 2) === '//' | |||
| ? '_blank' | |||
| : undefined} | |||
| className="w-full min-h-[36px] flex items-center pl-12"> | |||
| {subItem.name} | |||
| </PrefetchLink>)))} | |||
| @@ -2,85 +2,225 @@ import { useEffect, useRef, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import { useParams } from 'react-router-dom' | |||
| import ErrorScreen from '@/components/ErrorScreen' | |||
| 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 } | |||
| 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 (() => { | |||
| const { id } = useParams () | |||
| const commentsRef = useRef<HTMLDivElement> (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 [sending, setSending] = useState (false) | |||
| const [status, setStatus] = useState (200) | |||
| 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 [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 (() => { | |||
| 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<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 () => clearInterval (interval) | |||
| }, [id, theatreInfo.hostFlg, theatreInfo.postStartedAt, videoLength]) | |||
| return () => { | |||
| cancelled = true | |||
| } | |||
| }, [id]) | |||
| 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<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 (() => { | |||
| if (!(id) || !(theatreInfo.hostFlg) || loading || theatreInfo.postId != null) | |||
| return | |||
| 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) | |||
| } | |||
| }) () | |||
| return | |||
| return () => { | |||
| cancelled = true | |||
| } | |||
| }, [id, loading, theatreInfo.hostFlg, theatreInfo.postId]) | |||
| }, [id, theatreInfo.hostFlg, theatreInfo.postId]) | |||
| useEffect (() => { | |||
| setVideoLength (0) | |||
| 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) | |||
| } | |||
| }) () | |||
| }, [theatreInfo.postId, theatreInfo.postStartedAt]) | |||
| return () => { | |||
| cancelled = true | |||
| } | |||
| }, [theatreInfo.postId]) | |||
| 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) | |||
| @@ -88,8 +228,11 @@ export default (() => { | |||
| embedRef.current?.seek (targetTime) | |||
| } | |||
| if (status >= 400) | |||
| return <ErrorScreen status={status}/> | |||
| return ( | |||
| <MainArea> | |||
| <div className="md:flex md:flex-1"> | |||
| <Helmet> | |||
| {theatre && ( | |||
| <title> | |||
| @@ -99,16 +242,100 @@ export default (() => { | |||
| </title>)} | |||
| </Helmet> | |||
| {post && ( | |||
| <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={meta => { | |||
| syncPlayback (meta) | |||
| }}/>)} | |||
| </MainArea>) | |||
| 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 | |||
| @@ -52,7 +52,7 @@ export type FetchTagsParams = { | |||
| export type Menu = MenuItem[] | |||
| export type MenuItem = { | |||
| name: string | |||
| name: ReactNode | |||
| to: string | |||
| base?: string | |||
| subMenu: SubMenuItem[] } | |||
| @@ -117,7 +117,7 @@ export type PostTagChange = { | |||
| export type SubMenuItem = | |||
| | { component: ReactNode | |||
| visible: boolean } | |||
| | { name: string | |||
| | { name: ReactNode | |||
| to: string | |||
| visible?: boolean } | |||
| @@ -141,6 +141,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 | |||