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 = {