| @@ -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) | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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) | |||
| @@ -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' | |||
| @@ -0,0 +1,6 @@ | |||
| class TheatreProgramme < ApplicationRecord | |||
| self.primary_key = :theatre_id, :position | |||
| belongs_to :theatre | |||
| belongs_to :post | |||
| end | |||
| @@ -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] | |||
| @@ -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 | |||
| @@ -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" | |||
| @@ -63,7 +63,7 @@ const RouteTransitionWrapper = ({ user, setUser }: { | |||
| <Route path="/tags/:id/deerjikists" element={<DeerjikistDetailPage/>}/> | |||
| <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> | |||
| <Route path="/tags/changes" element={<TagHistoryPage/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage/>}/> | |||
| <Route path="/theatres/:id" element={<TheatreDetailPage user={user}/>}/> | |||
| <Route path="/materials" element={<MaterialBasePage/>}> | |||
| <Route index element={<MaterialListPage/>}/> | |||
| <Route path="new" element={<MaterialNewPage/>}/> | |||
| @@ -158,4 +158,4 @@ const App: FC = () => { | |||
| </>) | |||
| } | |||
| export default App | |||
| export default App | |||
| @@ -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<Props> = ({ 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"> | |||
| <h2>{item.name}</h2> | |||
| </motion.div> | |||
| {item.subMenu | |||
| @@ -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) => | |||
| [( | |||
| <div key={`${ comment.no }-content`} className="w-full"> | |||
| {comment.deleted | |||
| ? ( | |||
| <span className="text-sm font-bold"> | |||
| 削除されました. | |||
| </span>) | |||
| : comment.content} | |||
| </div>), | |||
| ( | |||
| <div key={`${ comment.no }-user`} className="w-full text-sm text-right"> | |||
| by {comment.user | |||
| ? (comment.user.name || `名もなきニジラー(#${ comment.user.id })`) | |||
| : '運営'} | |||
| </div>), | |||
| ( | |||
| <div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right"> | |||
| {dateString (comment.createdAt)} | |||
| </div>)] | |||
| type Props = { user: User } | |||
| const TheatreDetailPage: FC<Props> = ({ user }: Props) => { | |||
| const { id } = useParams () | |||
| const dialogue = useDialogue () | |||
| const commentsRef = useRef<HTMLDivElement> (null) | |||
| const embedRef = useRef<NiconicoViewerHandle> (null) | |||
| const loadingRef = useRef (false) | |||
| @@ -52,6 +83,7 @@ const TheatreDetailPage: FC = () => { | |||
| const [theatre, setTheatre] = useState<Theatre | null> (null) | |||
| const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO) | |||
| const [post, setPost] = useState<Post | null> (null) | |||
| const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([]) | |||
| const [videoLength, setVideoLength] = useState (0) | |||
| useEffect (() => { | |||
| @@ -118,7 +150,7 @@ const TheatreDetailPage: FC = () => { | |||
| { | |||
| const newComments = await apiGet<TheatreComment[]> ( | |||
| `/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<TheatreProgramme[]> ( | |||
| `/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 <ErrorScreen status={status}/> | |||
| return ( | |||
| <div className="md:flex md:flex-1 overflow-y-auto md:overflow-y-hidden"> | |||
| <Helmet> | |||
| <meta name="robots" content="noindex"/> | |||
| {theatre && ( | |||
| <title> | |||
| {'上映会場' | |||
| @@ -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 | |||
| @@ -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 = { | |||