956 行
26 KiB
TypeScript
956 行
26 KiB
TypeScript
import { motion } from 'framer-motion'
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Helmet } from 'react-helmet-async'
|
||
import { useParams } from 'react-router-dom'
|
||
|
||
import ErrorScreen from '@/components/ErrorScreen'
|
||
import PostEditForm from '@/components/PostEditForm'
|
||
import PostEmbed from '@/components/PostEmbed'
|
||
import PrefetchLink from '@/components/PrefetchLink'
|
||
import TagLink from '@/components/TagLink'
|
||
import FieldError from '@/components/common/FieldError'
|
||
import { useDialogue } from '@/components/dialogues/DialogueProvider'
|
||
import { Button } from '@/components/ui/button'
|
||
import { SITE_TITLE } from '@/config'
|
||
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
|
||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
|
||
import { fetchPost } from '@/lib/posts'
|
||
import { canEditContent } from '@/lib/users'
|
||
import { cn, dateString, inputClass } from '@/lib/utils'
|
||
import { useValidationErrors } from '@/lib/useValidationErrors'
|
||
|
||
import type { FC, FormEvent, ReactNode } from 'react'
|
||
|
||
import type { NiconicoViewerHandle,
|
||
Post,
|
||
Category,
|
||
Tag,
|
||
Theatre,
|
||
TheatreComment,
|
||
TheatreInfo,
|
||
TheatrePostSelectionWeights,
|
||
TheatreProgramme,
|
||
User } from '@/types'
|
||
|
||
type TheatreCommentField = 'content'
|
||
type TheatreLayoutMode = 'threeColumns' | 'tagsBottom' | 'commentsBottom'
|
||
type TagFlow = 'vertical' | 'horizontal'
|
||
|
||
const INITIAL_THEATRE_INFO: TheatreInfo =
|
||
{ hostFlg: false,
|
||
postId: null,
|
||
postStartedAt: null,
|
||
postElapsedMs: null,
|
||
watchingUsers: [],
|
||
skipVote: { votesCount: 0,
|
||
requiredCount: 1,
|
||
watchingUsersCount: 0,
|
||
voted: false } }
|
||
|
||
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
|
||
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
|
||
|
||
const LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
|
||
const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
||
|
||
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
||
threeColumns: '3 列',
|
||
tagsBottom: '2 列 A 型',
|
||
commentsBottom: '2 列 B 型' }
|
||
|
||
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
|
||
vertical: '縦並び',
|
||
horizontal: '横並び' }
|
||
|
||
|
||
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
|
||
user ? (user.name || `名もなきニジラー(#${ user.id })`) : '運営'
|
||
|
||
|
||
const commentBox = (
|
||
comment: TheatreComment,
|
||
programme: TheatreProgramme | null = null,
|
||
): ReactNode[] =>
|
||
[(
|
||
<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 {userName (comment.user)}
|
||
</div>),
|
||
(
|
||
<div key={`${ comment.no }-createdAt`} className="w-full text-sm text-right">
|
||
{dateString (comment.createdAt)}
|
||
</div>),
|
||
(
|
||
<div
|
||
key={`${ comment.no }-post`}
|
||
className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
|
||
{programme && (
|
||
<>
|
||
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
|
||
{programme.post.title || programme.post.url}
|
||
</PrefetchLink>
|
||
 へのコメント
|
||
</>)}
|
||
</div>)]
|
||
|
||
|
||
const compareTagName = (a: Tag, b: Tag): number =>
|
||
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
||
|
||
|
||
const tagsByCategory = (tags: Tag[]): Partial<Record<Category, Tag[]>> => {
|
||
const grouped: Partial<Record<Category, Tag[]>> = { }
|
||
|
||
for (const tag of tags)
|
||
{
|
||
grouped[tag.category] ??= []
|
||
grouped[tag.category]!.push (tag)
|
||
}
|
||
|
||
for (const cat of CATEGORIES)
|
||
grouped[cat]?.sort (compareTagName)
|
||
|
||
return grouped
|
||
}
|
||
|
||
|
||
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||
{ tags, compact, flow = 'vertical' },
|
||
) => {
|
||
const grouped = tagsByCategory (tags)
|
||
|
||
if (flow === 'horizontal')
|
||
{
|
||
return (
|
||
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
||
{CATEGORIES.flatMap (cat => grouped[cat] ?? []).map (tag => (
|
||
<li key={tag.id} className="text-left leading-tight">
|
||
<TagLink tag={tag} withCount={false}/>
|
||
</li>))}
|
||
</ul>)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{CATEGORIES.map (cat => {
|
||
const rows = grouped[cat] ?? []
|
||
if (rows.length === 0)
|
||
return null
|
||
|
||
return (
|
||
<div key={cat}>
|
||
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
|
||
{CATEGORY_NAMES[cat]}
|
||
</div>
|
||
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
||
{rows.map (tag => (
|
||
<li key={tag.id} className="text-left leading-tight">
|
||
<TagLink tag={tag} withCount={false}/>
|
||
</li>))}
|
||
</ul>
|
||
</div>)
|
||
})}
|
||
</div>)
|
||
}
|
||
|
||
|
||
type Props = { user: User | null }
|
||
|
||
|
||
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)
|
||
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||
const theatreInfoReceivedAtRef = useRef (performance.now ())
|
||
const videoLengthRef = useRef (0)
|
||
const lastCommentNoRef = useRef (0)
|
||
|
||
const [comments, setComments] = useState<TheatreComment[]> ([])
|
||
const [content, setContent] = useState ('')
|
||
const [editingPost, setEditingPost] = useState<Post | null> (null)
|
||
const [loading, setLoading] = useState (false)
|
||
const [programmes, setProgrammes] = useState<TheatreProgramme[]> ([])
|
||
const [sending, setSending] = useState (false)
|
||
const [status, setStatus] = useState (200)
|
||
const [theatre, setTheatre] = useState<Theatre | null> (null)
|
||
const [theatreInfo, setTheatreInfo] = useState<TheatreInfo> (INITIAL_THEATRE_INFO)
|
||
const [post, setPost] = useState<Post | null> (null)
|
||
const [videoLength, setVideoLength] = useState (0)
|
||
const [weights, setWeights] = useState<TheatrePostSelectionWeights> (INITIAL_WEIGHTS)
|
||
const [layoutMode, setLayoutMode] = useState<TheatreLayoutMode> (() => {
|
||
const stored = localStorage.getItem (LAYOUT_STORAGE_KEY)
|
||
return (
|
||
((['threeColumns', 'tagsBottom', 'commentsBottom'] as TheatreLayoutMode[])
|
||
.includes (stored as TheatreLayoutMode))
|
||
? (stored as TheatreLayoutMode)
|
||
: 'threeColumns')
|
||
})
|
||
const [tagFlow, setTagFlow] = useState<TagFlow> (() => {
|
||
const stored = localStorage.getItem (TAG_FLOW_STORAGE_KEY)
|
||
return (
|
||
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
|
||
? (stored as TagFlow)
|
||
: 'vertical')
|
||
})
|
||
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
||
useValidationErrors<TheatreCommentField> ()
|
||
|
||
const changeLayoutMode = (mode: TheatreLayoutMode) => {
|
||
setLayoutMode (mode)
|
||
localStorage.setItem (LAYOUT_STORAGE_KEY, mode)
|
||
}
|
||
|
||
const changeTagFlow = (flow: TagFlow) => {
|
||
setTagFlow (flow)
|
||
localStorage.setItem (TAG_FLOW_STORAGE_KEY, flow)
|
||
}
|
||
|
||
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
|
||
theatreInfoReceivedAtRef.current = performance.now ()
|
||
setTheatreInfo (nextInfo)
|
||
}, [])
|
||
|
||
const currentPostElapsedMs = useCallback (
|
||
(info: TheatreInfo = theatreInfoRef.current): number => {
|
||
if (info.postElapsedMs == null)
|
||
return 0
|
||
|
||
return Math.max (
|
||
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
|
||
0)
|
||
}, [])
|
||
|
||
const refreshProgrammes = useCallback (async () => {
|
||
if (!(id))
|
||
return
|
||
|
||
setProgrammes (await apiGet<TheatreProgramme[]> (
|
||
`/theatres/${ id }/programmes`, { params: { limit: '100' } }))
|
||
}, [id])
|
||
|
||
const refreshWeights = useCallback (async () => {
|
||
if (!(id))
|
||
return
|
||
|
||
setWeights (await apiGet<TheatrePostSelectionWeights> (
|
||
`/theatres/${ id }/post_selection_weights`))
|
||
}, [id])
|
||
|
||
const advancePost = useCallback (async () => {
|
||
if (!(id))
|
||
return
|
||
|
||
setLoading (true)
|
||
try
|
||
{
|
||
await apiPatch<void> (`/theatres/${ id }/next_post`)
|
||
await refreshProgrammes ()
|
||
await refreshWeights ()
|
||
}
|
||
catch (error)
|
||
{
|
||
console.error (error)
|
||
}
|
||
finally
|
||
{
|
||
setLoading (false)
|
||
}
|
||
}, [id, refreshProgrammes, refreshWeights])
|
||
|
||
useEffect (() => {
|
||
loadingRef.current = loading
|
||
}, [loading])
|
||
|
||
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 ([])
|
||
setEditingPost (null)
|
||
setPost (null)
|
||
setProgrammes ([])
|
||
setTheatre (null)
|
||
theatreInfoReceivedAtRef.current = performance.now ()
|
||
setTheatreInfo (INITIAL_THEATRE_INFO)
|
||
setVideoLength (0)
|
||
setWeights (INITIAL_WEIGHTS)
|
||
lastCommentNoRef.current = 0
|
||
|
||
void (async () => {
|
||
try
|
||
{
|
||
const data = await apiGet<Theatre> (`/theatres/${ id }`)
|
||
if (!(cancelled))
|
||
setTheatre (data)
|
||
}
|
||
catch (error)
|
||
{
|
||
setStatus (isApiError (error) ? error.response?.status ?? 200 : 200)
|
||
}
|
||
}) ()
|
||
|
||
void refreshProgrammes ()
|
||
void refreshWeights ()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [id, refreshProgrammes, refreshWeights])
|
||
|
||
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, limit: '20' } })
|
||
|
||
if (!(cancelled) && newComments.length > 0)
|
||
{
|
||
lastCommentNoRef.current = newComments[0].no
|
||
setComments (prev => [...newComments, ...prev])
|
||
}
|
||
|
||
const currentInfo = theatreInfoRef.current
|
||
const ended =
|
||
currentInfo.hostFlg
|
||
&& currentInfo.postStartedAt
|
||
&& videoLengthRef.current > 0
|
||
&& currentPostElapsedMs (currentInfo) > videoLengthRef.current + 3_000
|
||
|
||
if (ended)
|
||
{
|
||
if (!(cancelled))
|
||
{
|
||
setTheatreInfo (prev => ({
|
||
...prev,
|
||
postId: null,
|
||
postStartedAt: null,
|
||
postElapsedMs: null }))
|
||
}
|
||
|
||
return
|
||
}
|
||
|
||
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`)
|
||
if (!(cancelled))
|
||
applyTheatreInfo (nextInfo)
|
||
}
|
||
catch (error)
|
||
{
|
||
console.error (error)
|
||
}
|
||
finally
|
||
{
|
||
running = false
|
||
}
|
||
}
|
||
|
||
tick ()
|
||
const interval = setInterval (() => tick (), 1_500)
|
||
|
||
return () => {
|
||
cancelled = true
|
||
clearInterval (interval)
|
||
}
|
||
}, [applyTheatreInfo, currentPostElapsedMs, id])
|
||
|
||
useEffect (() => {
|
||
if (!(id) || !(theatreInfo.hostFlg) || loadingRef.current || theatreInfo.postId != null)
|
||
return
|
||
|
||
let cancelled = false
|
||
|
||
void (async () => {
|
||
await advancePost ()
|
||
if (cancelled)
|
||
setLoading (false)
|
||
}) ()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [advancePost, id, theatreInfo.hostFlg, theatreInfo.postId])
|
||
|
||
useEffect (() => {
|
||
setVideoLength (0)
|
||
|
||
if (theatreInfo.postId == null)
|
||
{
|
||
setPost (null)
|
||
return
|
||
}
|
||
|
||
let cancelled = false
|
||
|
||
void (async () => {
|
||
try
|
||
{
|
||
const nextPost = await fetchPost (String (theatreInfo.postId))
|
||
if (!(cancelled))
|
||
setPost (nextPost)
|
||
}
|
||
catch (error)
|
||
{
|
||
console.error (error)
|
||
}
|
||
}) ()
|
||
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [theatreInfo.postId])
|
||
|
||
useEffect (() => {
|
||
void refreshProgrammes ()
|
||
}, [refreshProgrammes, theatreInfo.postId])
|
||
|
||
const syncPlaybackTime = (currentTimeMs: number): number | void => {
|
||
if (!(theatreInfo.postStartedAt))
|
||
return
|
||
|
||
const targetTime = Math.min (
|
||
currentPostElapsedMs (theatreInfo),
|
||
videoLength)
|
||
|
||
const drift = Math.abs (currentTimeMs - targetTime)
|
||
|
||
if (drift > 5_000)
|
||
embedRef.current?.seek (targetTime)
|
||
|
||
return targetTime
|
||
}
|
||
|
||
const handlePlaybackError = async () => {
|
||
if (!(theatreInfoRef.current.hostFlg) || loadingRef.current)
|
||
return
|
||
|
||
loadingRef.current = true
|
||
try
|
||
{
|
||
await advancePost ()
|
||
}
|
||
finally
|
||
{
|
||
loadingRef.current = false
|
||
}
|
||
}
|
||
|
||
const handleVideoReady = (durationMs: number) => {
|
||
const playableDurationMs =
|
||
Number.isFinite (durationMs)
|
||
? durationMs
|
||
: 0
|
||
|
||
setVideoLength (playableDurationMs)
|
||
|
||
if (playableDurationMs <= 0)
|
||
{
|
||
void handlePlaybackError ()
|
||
return
|
||
}
|
||
|
||
embedRef.current?.play ()
|
||
}
|
||
|
||
const handleSkipVote = async () => {
|
||
if (!(id) || !(post))
|
||
return
|
||
|
||
setLoading (true)
|
||
try
|
||
{
|
||
const nextInfo =
|
||
theatreInfo.skipVote.voted
|
||
? await apiDelete<TheatreInfo> (
|
||
`/theatres/${ id }/skip_vote`, { params: { post_id: post.id } })
|
||
: await apiPut<TheatreInfo> (
|
||
`/theatres/${ id }/skip_vote`, { post_id: post.id })
|
||
|
||
applyTheatreInfo (nextInfo)
|
||
|
||
if (nextInfo.skipped)
|
||
{
|
||
setPost (null)
|
||
await refreshProgrammes ()
|
||
await refreshWeights ()
|
||
}
|
||
}
|
||
catch (error)
|
||
{
|
||
if (isApiError (error) && error.response?.status === 409)
|
||
applyTheatreInfo (await apiPut<TheatreInfo> (`/theatres/${ id }/watching`))
|
||
|
||
console.error (error)
|
||
}
|
||
finally
|
||
{
|
||
setLoading (false)
|
||
}
|
||
}
|
||
|
||
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)
|
||
if (idx >= 0)
|
||
rtn[idx] = { ...rtn[idx], deleted: true, content: null }
|
||
return rtn
|
||
})
|
||
}
|
||
catch
|
||
{
|
||
;
|
||
}
|
||
}
|
||
|
||
const handleCommentSubmit = async (e: FormEvent) => {
|
||
e.preventDefault ()
|
||
|
||
if (!(content))
|
||
return
|
||
|
||
try
|
||
{
|
||
setSending (true)
|
||
clearValidationErrors ()
|
||
await apiPost (`/theatres/${ id }/comments`, { content })
|
||
setContent ('')
|
||
commentsRef.current?.scrollTo ({ top: 0, behavior: 'smooth' })
|
||
}
|
||
catch (error)
|
||
{
|
||
applyValidationError (error)
|
||
}
|
||
finally
|
||
{
|
||
setSending (false)
|
||
}
|
||
}
|
||
|
||
const skipVote = theatreInfo.skipVote
|
||
const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場'
|
||
const postTags = post?.tags ?? []
|
||
const programmesAsc = useMemo (
|
||
() => [...programmes].sort (
|
||
(a, b) => Date.parse (a.createdAt) - Date.parse (b.createdAt)),
|
||
[programmes])
|
||
|
||
const programmeForComment = useCallback ((comment: TheatreComment): TheatreProgramme | null => {
|
||
const commentedAt = Date.parse (comment.createdAt)
|
||
let found: TheatreProgramme | null = null
|
||
|
||
for (const programme of programmesAsc)
|
||
{
|
||
const startedAt = Date.parse (programme.createdAt)
|
||
if (startedAt > commentedAt)
|
||
break
|
||
|
||
found = programme
|
||
}
|
||
|
||
return found
|
||
}, [programmesAsc])
|
||
|
||
if (status >= 400)
|
||
return <ErrorScreen status={status}/>
|
||
|
||
const tagPanel = (
|
||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<h2 className="font-bold">タグ</h2>
|
||
{layoutMode === 'tagsBottom' && (
|
||
<div className="hidden gap-2 md:flex">
|
||
{(Object.keys (TAG_FLOW_LABELS) as TagFlow[]).map (flow => (
|
||
<Button
|
||
key={flow}
|
||
type="button"
|
||
size="sm"
|
||
variant={tagFlow === flow ? 'default' : 'outline'}
|
||
onClick={() => changeTagFlow (flow)}>
|
||
{TAG_FLOW_LABELS[flow]}
|
||
</Button>))}
|
||
</div>)}
|
||
</div>
|
||
{postTags.length === 0
|
||
? <div className="text-sm text-zinc-500">タグはありません。</div>
|
||
: <TagList tags={postTags} flow={layoutMode === 'tagsBottom' ? tagFlow : 'vertical'}/>}
|
||
</section>)
|
||
|
||
const commentsPanel = (
|
||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||
<h2 className="mb-3 font-bold">コメント</h2>
|
||
<form onSubmit={handleCommentSubmit}>
|
||
<input
|
||
className={inputClass ((fieldErrors.content ?? []).length > 0)}
|
||
type="text"
|
||
placeholder="ここにコメントを入力"
|
||
value={content}
|
||
onChange={e => setContent (e.target.value)}
|
||
disabled={sending}/>
|
||
<FieldError messages={fieldErrors.content}/>
|
||
</form>
|
||
|
||
<div
|
||
ref={commentsRef}
|
||
className="mt-3 max-h-[48vh] overflow-y-auto rounded border border-zinc-200
|
||
dark:border-zinc-800">
|
||
{comments.map (comment => {
|
||
const commentProgramme = programmeForComment (comment)
|
||
return (
|
||
<div
|
||
key={comment.no}
|
||
className="group relative border-t border-zinc-100 p-2 first:border-t-0
|
||
hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800">
|
||
{(user && comment.user?.id === user.id && !(comment.deleted)) && (
|
||
<button
|
||
type="button"
|
||
className="absolute left-1 top-1 hidden rounded px-1 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="my-3 w-120 rounded border border-black p-2
|
||
dark:border-white">
|
||
{commentBox (comment, commentProgramme)}
|
||
</div>),
|
||
confirmText: '削除',
|
||
variant: 'danger' })))
|
||
return
|
||
|
||
await handleDelete (comment.no)
|
||
}}>
|
||
×
|
||
</button>)}
|
||
{commentBox (comment, commentProgramme)}
|
||
</div>)
|
||
})}
|
||
</div>
|
||
</section>)
|
||
|
||
const participantsPanel = (
|
||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||
<h2 className="mb-3 font-bold">参加者</h2>
|
||
<div className="space-y-1">
|
||
{theatreInfo.watchingUsers.map (watchingUser => (
|
||
<div key={watchingUser.id} className="flex justify-between gap-2 text-sm">
|
||
<span>{userName (watchingUser)}</span>
|
||
{watchingUser.id === user?.id && <span className="text-zinc-500">お前</span>}
|
||
</div>))}
|
||
</div>
|
||
</section>)
|
||
|
||
const historyPanel = (
|
||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||
<h2 className="mb-3 font-bold">再生履歴</h2>
|
||
<div className="rounded border border-zinc-300 dark:border-zinc-800 max-h-72
|
||
overflow-y-auto">
|
||
{programmes.length === 0
|
||
? <div className="text-sm text-zinc-500">まだ履歴はありません。</div>
|
||
: (
|
||
programmes.map (programme => (
|
||
<div
|
||
key={`${ programme.theatreId }-${ programme.position }`}
|
||
className="border-zinc-100 p-2 text-sm first:border-t-0
|
||
dark:border-zinc-800">
|
||
<PrefetchLink
|
||
to={`/posts/${ programme.post.id }`}
|
||
className="font-bold hover:underline">
|
||
{programme.post.title || programme.post.url}
|
||
</PrefetchLink>
|
||
<div className="text-xs text-zinc-500">
|
||
{dateString (programme.createdAt)}
|
||
</div>
|
||
</div>)))}
|
||
</div>
|
||
</section>)
|
||
|
||
const weightsPanel = (
|
||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||
<div className="mb-3 flex items-center justify-between gap-3">
|
||
<h2 className="font-bold">抽選重み</h2>
|
||
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
|
||
更新
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="mx-4 grid gap-16 xl:grid-cols-3">
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-bold">出にくいタグ</h3>
|
||
<div className="space-y-1 text-sm">
|
||
{weights.tagPenalties.length === 0
|
||
? <div className="text-zinc-500">まだ減点はありません。</div>
|
||
: (
|
||
weights.tagPenalties.slice (0, 12).map (row => (
|
||
<div
|
||
key={row.tag.id}
|
||
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-2
|
||
text-left">
|
||
<div className="min-w-0 text-left">
|
||
<TagLink tag={row.tag} withCount={false}/>
|
||
</div>
|
||
<span className="font-mono">{row.penalty}</span>
|
||
</div>)))}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-bold">出にくい候補</h3>
|
||
<WeightRows rows={weights.lightestPosts}/>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 className="mb-2 text-sm font-bold">出やすい候補</h3>
|
||
<WeightRows rows={weights.heaviestPosts}/>
|
||
</div>
|
||
</div>
|
||
</section>)
|
||
|
||
return (
|
||
<motion.div
|
||
layout="position"
|
||
transition={{ layout: { duration: .2, ease: 'easeOut' } }}
|
||
className="min-h-0 flex-1 overflow-y-auto bg-zinc-50 text-zinc-950
|
||
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
|
||
<Helmet>
|
||
<meta name="robots" content="noindex"/>
|
||
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
|
||
</Helmet>
|
||
|
||
<div className={cn (
|
||
'grid min-h-full gap-4 overflow-visible md:h-full md:overflow-hidden',
|
||
(layoutMode === 'threeColumns'
|
||
&& ['md:grid-cols-[16rem_minmax(0,1fr)_22rem]',
|
||
'xl:grid-cols-[18rem_minmax(0,1fr)_24rem]']),
|
||
(layoutMode === 'tagsBottom'
|
||
&& 'md:grid-cols-[minmax(0,1fr)_22rem] xl:grid-cols-[minmax(0,1fr)_24rem]'),
|
||
(layoutMode === 'commentsBottom'
|
||
&& 'md:grid-cols-[16rem_minmax(0,1fr)] xl:grid-cols-[18rem_minmax(0,1fr)]'))}>
|
||
{layoutMode !== 'tagsBottom' && (
|
||
<motion.aside
|
||
layout="position"
|
||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto
|
||
md:[direction:rtl]">
|
||
<div className="md:[direction:ltr]">
|
||
{tagPanel}
|
||
</div>
|
||
</motion.aside>)}
|
||
|
||
<motion.main
|
||
layout="position"
|
||
className={cn ('order-1 min-w-0 space-y-4 md:order-none md:overflow-y-auto',
|
||
layoutMode === 'tagsBottom' && 'md:[direction:rtl]')}>
|
||
<div className={cn ('space-y-4', layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
||
<section className="overflow-hidden rounded border-zinc-300
|
||
dark:border-zinc-800">
|
||
<div className="flex flex-wrap items-center justify-between gap-3
|
||
border-zinc-200 px-4 py-3 dark:border-zinc-800">
|
||
<div>
|
||
<h1 className="text-lg font-bold">{theatreTitle}</h1>
|
||
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||
同接 {theatreInfo.watchingUsers.length} 人
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-2">
|
||
<div className="hidden flex-wrap gap-2 md:flex">
|
||
{(Object.keys (LAYOUT_LABELS) as TheatreLayoutMode[]).map (mode => (
|
||
<Button
|
||
key={mode}
|
||
type="button"
|
||
size="sm"
|
||
variant={layoutMode === mode ? 'default' : 'outline'}
|
||
onClick={() => changeLayoutMode (mode)}>
|
||
{LAYOUT_LABELS[mode]}
|
||
</Button>))}
|
||
</div>
|
||
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant={skipVote.voted ? 'secondary' : 'destructive'}
|
||
disabled={loading || !(post)}
|
||
onClick={handleSkipVote}>
|
||
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
|
||
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
|
||
</Button>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-center bg-black mx-4">
|
||
{post ? (
|
||
<PostEmbed
|
||
key={post.id}
|
||
ref={embedRef}
|
||
post={post}
|
||
onVideoReady={handleVideoReady}
|
||
onPlaybackChange={syncPlaybackTime}
|
||
onError={handlePlaybackError}/>) : (
|
||
<div className="grid min-h-72 place-items-center text-zinc-400">
|
||
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
|
||
</div>)}
|
||
</div>
|
||
|
||
<div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||
<div className="min-w-0">
|
||
<div className="text-xs font-bold text-zinc-500 dark:text-zinc-400">
|
||
再生中
|
||
</div>
|
||
{post ? (
|
||
<PrefetchLink
|
||
to={`/posts/${ post.id }`}
|
||
className="font-bold hover:underline">
|
||
{post.title || post.url}
|
||
</PrefetchLink>) : (
|
||
<span className="text-zinc-500">未選択</span>)}
|
||
</div>
|
||
|
||
{(post && canEditContent (user)) && (
|
||
<Button
|
||
type="button"
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!(post)}
|
||
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
|
||
{editingPost ? '閉じる' : '編輯'}
|
||
</Button>)}
|
||
</div>
|
||
</section>
|
||
|
||
{editingPost && (
|
||
<section className="rounded border border-amber-300 bg-amber-50 mx-4 p-4
|
||
dark:border-amber-800 dark:bg-amber-950/30">
|
||
<div className="mb-3">
|
||
<h2 className="font-bold">編輯中の投稿</h2>
|
||
<p className="text-sm text-amber-900 dark:text-amber-100">
|
||
<PrefetchLink
|
||
to={`/posts/${ editingPost.id }`}
|
||
className="mx-1 font-bold underline">
|
||
{editingPost.title || editingPost.url}
|
||
</PrefetchLink>
|
||
を編輯中……
|
||
</p>
|
||
</div>
|
||
|
||
<PostEditForm
|
||
post={editingPost}
|
||
onSave={newPost => {
|
||
setEditingPost (newPost)
|
||
if (post?.id === newPost.id)
|
||
setPost (newPost)
|
||
void refreshWeights ()
|
||
}}/>
|
||
</section>)}
|
||
|
||
<div className="md:hidden">
|
||
{commentsPanel}
|
||
</div>
|
||
|
||
{layoutMode === 'commentsBottom' && (
|
||
<div className="hidden md:block">
|
||
{commentsPanel}
|
||
</div>)}
|
||
|
||
<div className="md:hidden">
|
||
{tagPanel}
|
||
</div>
|
||
|
||
{layoutMode === 'tagsBottom' && (
|
||
<div className="hidden md:block">
|
||
{tagPanel}
|
||
</div>)}
|
||
|
||
{historyPanel}
|
||
{weightsPanel}
|
||
|
||
<div className="md:hidden">
|
||
{participantsPanel}
|
||
</div>
|
||
|
||
{layoutMode === 'commentsBottom' && (
|
||
<div className="hidden md:block">
|
||
{participantsPanel}
|
||
</div>)}
|
||
</div>
|
||
</motion.main>
|
||
|
||
{layoutMode !== 'commentsBottom' && (
|
||
<motion.aside
|
||
layout="position"
|
||
className="hidden min-w-0 space-y-4 md:order-none md:block md:overflow-y-auto">
|
||
{commentsPanel}
|
||
{participantsPanel}
|
||
</motion.aside>)}
|
||
</div>
|
||
</motion.div>)
|
||
}
|
||
|
||
|
||
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => (
|
||
<div className="space-y-2 text-sm">
|
||
{rows.length === 0
|
||
? <div className="text-zinc-500">候補はありません。</div>
|
||
: (
|
||
rows.slice (0, 8).map (row => (
|
||
<div
|
||
key={row.post.id}
|
||
className="border-zinc-100 pt-2 first:border-t-0 first:pt-0
|
||
dark:border-zinc-800">
|
||
<PrefetchLink
|
||
to={`/posts/${ row.post.id }`}
|
||
className="line-clamp-1 font-bold hover:underline">
|
||
{row.post.title || row.post.url}
|
||
</PrefetchLink>
|
||
<div className="flex justify-between gap-2 text-xs text-zinc-500">
|
||
<span>penalty {row.penalty}</span>
|
||
<span>weight {row.weight.toFixed (3)}</span>
|
||
</div>
|
||
</div>)))}
|
||
</div>)
|
||
|
||
|
||
export default TheatreDetailPage
|