1062 行
30 KiB
TypeScript
1062 行
30 KiB
TypeScript
import { motion } from 'framer-motion'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useParams } from 'react-router-dom'
|
|
|
|
import { useLocale } from '@/lib/useLocale'
|
|
import ErrorScreen from '@/components/ErrorScreen'
|
|
import PostEditForm from '@/components/PostEditForm'
|
|
import PostEmbed from '@/components/PostEmbed'
|
|
import PrefetchLink from '@/components/PrefetchLink'
|
|
import LocalisedText from '@/components/LocalisedText'
|
|
import TagLink from '@/components/TagLink'
|
|
import FieldError from '@/components/common/FieldError'
|
|
import useDialogue from '@/lib/dialogues/useDialogue'
|
|
import SidebarComponent from '@/components/layout/SidebarComponent'
|
|
import { Button } from '@/components/ui/button'
|
|
import PageHead from '@/components/common/PageHead'
|
|
import { CATEGORIES } from '@/consts'
|
|
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
|
|
import { clientAnimationTransition,
|
|
clientScrollBehaviour } from '@/lib/clientAnimation'
|
|
import { fetchPost } from '@/lib/posts'
|
|
import { theatresKeys } from '@/lib/queryKeys'
|
|
import {
|
|
getClientTheatreLayoutMode,
|
|
getClientTheatreTagFlow,
|
|
setClientTheatreLayoutMode,
|
|
setClientTheatreTagFlow,
|
|
} from '@/lib/settings'
|
|
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
|
|
import { canEditContent } from '@/lib/users'
|
|
import { cn, localisedDateString, inputClass } from '@/lib/utils'
|
|
import { useValidationErrors } from '@/lib/useValidationErrors'
|
|
import { useUITranslation } from '@/lib/useUITranslation'
|
|
|
|
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 MIN_MAIN_WIDTH = 360
|
|
const SIDEBAR_GAP_WIDTH = 16
|
|
|
|
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
|
threeColumns: 'layouts.threeColumns',
|
|
tagsBottom: 'layouts.tagsBottom',
|
|
commentsBottom: 'layouts.commentsBottom' }
|
|
|
|
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
|
|
vertical: 'flows.vertical',
|
|
horizontal: 'flows.horizontal' }
|
|
|
|
|
|
const compareTagName = (a: Tag, b: Tag): number =>
|
|
a.name === b.name ? 0 : (a.name < b.name ? -1 : 1)
|
|
|
|
|
|
const flattenTags = (tags: Tag[]): Tag[] => {
|
|
const flattened: Tag[] = []
|
|
const seen = new Set<number> ()
|
|
|
|
const visit = (tag: Tag) => {
|
|
if (seen.has (tag.id))
|
|
return
|
|
|
|
seen.add (tag.id)
|
|
flattened.push (tag)
|
|
|
|
for (const child of tag.children ?? [])
|
|
visit (child)
|
|
}
|
|
|
|
for (const tag of tags)
|
|
visit (tag)
|
|
|
|
return flattened
|
|
}
|
|
|
|
|
|
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 renderReadonlyTagTree = (
|
|
tag: Tag,
|
|
nestLevel: number,
|
|
path: string,
|
|
ancestors: Set<number> = new Set<number> (),
|
|
): ReactNode[] => {
|
|
const key = `${ path }-${ tag.id }`
|
|
const nextAncestors = new Set (ancestors)
|
|
nextAncestors.add (tag.id)
|
|
|
|
return [
|
|
(
|
|
<li key={key} className="text-left leading-tight">
|
|
<TagLink tag={tag} nestLevel={nestLevel} withCount={false}/>
|
|
</li>),
|
|
...((tag.children ?? [])
|
|
.filter (child => !(nextAncestors.has (child.id)))
|
|
.sort (compareTagName)
|
|
.flatMap (child =>
|
|
renderReadonlyTagTree (child, nestLevel + 1, key, nextAncestors)))]
|
|
}
|
|
|
|
|
|
const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
|
{ tags, compact, flow = 'vertical' }) => {
|
|
const { label } = useUITranslation ()
|
|
const horizontalGrouped = tagsByCategory (flattenTags (tags))
|
|
const verticalGrouped = tagsByCategory (tags)
|
|
|
|
if (flow === 'horizontal')
|
|
{
|
|
return (
|
|
<ul className={cn ('flex flex-wrap gap-x-3 gap-y-1', compact && 'text-sm')}>
|
|
{CATEGORIES.flatMap (cat => horizontalGrouped[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 = verticalGrouped[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">
|
|
{label (`categories.${cat}`)}
|
|
</div>
|
|
<ul className={cn ('space-y-1', compact && 'text-sm')}>
|
|
{rows.flatMap (tag =>
|
|
renderReadonlyTagTree (tag, 0, `cat-${ cat }`))}
|
|
</ul>
|
|
</div>)
|
|
})}
|
|
</div>)
|
|
}
|
|
|
|
|
|
type Props = { user: User | null }
|
|
|
|
|
|
const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
|
const locale = useLocale ()
|
|
const { message, label } = useUITranslation ('theatres')
|
|
const userName = (entry: Pick<User, 'id' | 'name'> | null | undefined): ReactNode =>
|
|
entry ? (entry.name || label ('history:anonymous', { id: entry.id })) : label ('staff')
|
|
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">{label ('deleted')}</span>
|
|
: comment.content}
|
|
</div>),
|
|
(<div key={`${comment.no}-user`} className="w-full text-sm text-right">
|
|
{label ('authorPrefix')}{userName (comment.user)}
|
|
</div>),
|
|
(<div key={`${comment.no}-createdAt`} className="w-full text-sm text-right">
|
|
<LocalisedText language={locale}>
|
|
{localisedDateString (comment.createdAt, locale)}
|
|
</LocalisedText>
|
|
</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>
|
|
{label ('commentSuffix')}
|
|
</>)}
|
|
</div>)]
|
|
const behaviourSettings = useClientBehaviourSettings ()
|
|
const animationMode = behaviourSettings.animation ?? 'normal'
|
|
const layoutTransition = clientAnimationTransition (
|
|
animationMode,
|
|
{ normal: { duration: .2, ease: 'easeOut' as const } },
|
|
)
|
|
const { id } = useParams ()
|
|
const dialogue = useDialogue ()
|
|
|
|
const commentsRef = useRef<HTMLDivElement> (null)
|
|
const embedRef = useRef<NiconicoViewerHandle> (null)
|
|
const loadingRef = useRef (false)
|
|
const sidebarContainerRef = useRef<HTMLDivElement> (null)
|
|
const theatreInfoRef = useRef<TheatreInfo> (INITIAL_THEATRE_INFO)
|
|
const theatreInfoReceivedAtRef = useRef (performance.now ())
|
|
const videoLengthRef = useRef (0)
|
|
const lastCommentNoRef = useRef (0)
|
|
|
|
const [leftSidebarWidth, setLeftSidebarWidth] = useState (256)
|
|
const [rightSidebarWidth, setRightSidebarWidth] = useState (256)
|
|
const [sidebarContainerWidth, setSidebarContainerWidth] = useState (0)
|
|
const [comments, setComments] = useState<TheatreComment[]> ([])
|
|
const [content, setContent] = useState ('')
|
|
const [editingPost, setEditingPost] = useState<Post | null> (null)
|
|
const [loading, setLoading] = useState (false)
|
|
const { data: initialTheatre, error: theatreError } = useQuery ({
|
|
queryKey: theatresKeys.show (id ?? '', locale),
|
|
queryFn: () => apiGet<Theatre> (`/theatres/${ id }`, { locale }),
|
|
enabled: id != null })
|
|
const { data: initialProgrammes } = useQuery ({
|
|
queryKey: theatresKeys.programmes (id ?? '', locale),
|
|
queryFn: () => apiGet<TheatreProgramme[]> (`/theatres/${ id }/programmes`, {
|
|
locale, params: { limit: '100' } }), enabled: id != null })
|
|
const [programmes, setProgrammes] = useState<TheatreProgramme[]> (initialProgrammes ?? [])
|
|
const [sending, setSending] = useState (false)
|
|
const status = isApiError (theatreError) ? theatreError.response?.status ?? 200 : 200
|
|
const [theatre, setTheatre] = useState<Theatre | null> (initialTheatre ?? 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> ('threeColumns')
|
|
const [tagFlow, setTagFlow] = useState<TagFlow> ('vertical')
|
|
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
|
useValidationErrors<TheatreCommentField> ()
|
|
|
|
const changeLayoutMode = (mode: TheatreLayoutMode) => {
|
|
setLayoutMode (mode)
|
|
setClientTheatreLayoutMode (mode)
|
|
}
|
|
|
|
useEffect (() => {
|
|
setLayoutMode (getClientTheatreLayoutMode ())
|
|
setTagFlow (getClientTheatreTagFlow ())
|
|
}, [])
|
|
|
|
const changeTagFlow = (flow: TagFlow) => {
|
|
setTagFlow (flow)
|
|
setClientTheatreTagFlow (flow)
|
|
}
|
|
|
|
const applyTheatreInfo = useCallback ((nextInfo: TheatreInfo) => {
|
|
theatreInfoReceivedAtRef.current = performance.now ()
|
|
theatreInfoRef.current = nextInfo
|
|
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`, { locale, params: { limit: '100' } }))
|
|
}, [id, locale])
|
|
|
|
const refreshWeights = useCallback (async () => {
|
|
if (!(id))
|
|
return
|
|
|
|
setWeights (await apiGet<TheatrePostSelectionWeights> (
|
|
`/theatres/${ id }/post_selection_weights`, { locale }))
|
|
}, [id, locale])
|
|
|
|
const advancePost = useCallback (async () => {
|
|
if (!(id))
|
|
return
|
|
|
|
setLoading (true)
|
|
try
|
|
{
|
|
await apiPatch<void> (`/theatres/${ id }/next_post`, { }, { locale })
|
|
await refreshProgrammes ()
|
|
await refreshWeights ()
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error (error)
|
|
}
|
|
finally
|
|
{
|
|
setLoading (false)
|
|
}
|
|
}, [id, refreshProgrammes, refreshWeights, locale])
|
|
|
|
useEffect (() => {
|
|
loadingRef.current = loading
|
|
}, [loading])
|
|
|
|
useEffect (() => {
|
|
theatreInfoRef.current = theatreInfo
|
|
}, [theatreInfo])
|
|
|
|
useEffect (() => {
|
|
videoLengthRef.current = videoLength
|
|
}, [videoLength])
|
|
|
|
useEffect (() => {
|
|
lastCommentNoRef.current = comments[0]?.no ?? 0
|
|
}, [comments])
|
|
|
|
useEffect (() => {
|
|
const updateSidebarContainerWidth = () => {
|
|
const nextWidth = sidebarContainerRef.current?.clientWidth ?? window.innerWidth
|
|
setSidebarContainerWidth (nextWidth)
|
|
}
|
|
|
|
updateSidebarContainerWidth ()
|
|
window.addEventListener ('resize', updateSidebarContainerWidth)
|
|
return () => window.removeEventListener ('resize', updateSidebarContainerWidth)
|
|
}, [])
|
|
|
|
useEffect (() => {
|
|
if (!(id))
|
|
return
|
|
|
|
setComments ([])
|
|
setEditingPost (null)
|
|
setPost (null)
|
|
setProgrammes (initialProgrammes ?? [])
|
|
setTheatre (initialTheatre ?? null)
|
|
theatreInfoReceivedAtRef.current = performance.now ()
|
|
setTheatreInfo (INITIAL_THEATRE_INFO)
|
|
setVideoLength (0)
|
|
setWeights (INITIAL_WEIGHTS)
|
|
lastCommentNoRef.current = 0
|
|
|
|
void refreshWeights ()
|
|
|
|
}, [id, initialProgrammes, initialTheatre, refreshWeights, locale])
|
|
|
|
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`,
|
|
{ locale, params: { no_gt: lastCommentNoRef.current, limit: '20' } })
|
|
|
|
if (!(cancelled) && newComments.length > 0)
|
|
{
|
|
lastCommentNoRef.current = newComments[0].no
|
|
setComments (prev => [...newComments, ...prev])
|
|
}
|
|
|
|
if (loadingRef.current)
|
|
return
|
|
|
|
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 watchingRequestedAt = performance.now ()
|
|
const nextInfo = await apiPut<TheatreInfo> (`/theatres/${ id }/watching`, { }, { locale })
|
|
if (!(cancelled) && watchingRequestedAt >= theatreInfoReceivedAtRef.current)
|
|
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, locale])
|
|
|
|
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), locale)
|
|
if (!(cancelled))
|
|
setPost (nextPost)
|
|
}
|
|
catch (error)
|
|
{
|
|
console.error (error)
|
|
}
|
|
}) ()
|
|
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [theatreInfo.postId, locale])
|
|
|
|
useEffect (() => {
|
|
void refreshProgrammes ()
|
|
}, [refreshProgrammes, theatreInfo.postId])
|
|
|
|
const syncPlaybackTime = (currentTimeMs: number): number | void => {
|
|
if (!(theatreInfo.postStartedAt))
|
|
return
|
|
|
|
const currentVideoLength = videoLengthRef.current
|
|
if (currentVideoLength <= 0)
|
|
return
|
|
|
|
const targetTime = Math.min (
|
|
currentPostElapsedMs (theatreInfo),
|
|
currentVideoLength)
|
|
|
|
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)
|
|
videoLengthRef.current = playableDurationMs
|
|
|
|
if (playableDurationMs <= 0)
|
|
{
|
|
void handlePlaybackError ()
|
|
return
|
|
}
|
|
|
|
embedRef.current?.play ()
|
|
}
|
|
|
|
const handleSkipVote = async () => {
|
|
if (!(id) || !(post))
|
|
return
|
|
|
|
loadingRef.current = true
|
|
setLoading (true)
|
|
try
|
|
{
|
|
const nextInfo =
|
|
theatreInfo.skipVote.voted
|
|
? await apiDelete<TheatreInfo> (
|
|
`/theatres/${ id }/skip_vote`, { locale, params: { post_id: post.id } })
|
|
: await apiPut<TheatreInfo> (
|
|
`/theatres/${ id }/skip_vote`, { post_id: post.id }, { locale })
|
|
|
|
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`, { }, { locale }))
|
|
|
|
console.error (error)
|
|
}
|
|
finally
|
|
{
|
|
loadingRef.current = false
|
|
setLoading (false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (commentNo: number) => {
|
|
try
|
|
{
|
|
await apiDelete (`/theatres/${ id }/comments/${ commentNo }`, { locale })
|
|
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 }, { locale })
|
|
setContent ('')
|
|
commentsRef.current?.scrollTo ({
|
|
top: 0,
|
|
behavior: clientScrollBehaviour (animationMode) })
|
|
}
|
|
catch (error)
|
|
{
|
|
applyValidationError (error)
|
|
}
|
|
finally
|
|
{
|
|
setSending (false)
|
|
}
|
|
}
|
|
|
|
const skipVote = theatreInfo.skipVote
|
|
const theatreTitle = theatre?.name
|
|
? message ('namedTitle', { name: theatre.name })
|
|
: message ('title')
|
|
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 singleSidebarMaxWidth = Math.max (
|
|
192,
|
|
sidebarContainerWidth - MIN_MAIN_WIDTH - SIDEBAR_GAP_WIDTH)
|
|
const leftSidebarMaxWidth = layoutMode === 'threeColumns'
|
|
? Math.max (
|
|
192,
|
|
sidebarContainerWidth
|
|
- rightSidebarWidth
|
|
- MIN_MAIN_WIDTH
|
|
- SIDEBAR_GAP_WIDTH * 2)
|
|
: singleSidebarMaxWidth
|
|
const rightSidebarMaxWidth = layoutMode === 'threeColumns'
|
|
? Math.max (
|
|
192,
|
|
sidebarContainerWidth
|
|
- leftSidebarWidth
|
|
- MIN_MAIN_WIDTH
|
|
- SIDEBAR_GAP_WIDTH * 2)
|
|
: singleSidebarMaxWidth
|
|
|
|
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">{label ('navigation:tags')}</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)}>
|
|
{label (TAG_FLOW_LABELS[flow])}
|
|
</Button>))}
|
|
</div>)}
|
|
</div>
|
|
{postTags.length === 0
|
|
? <div className="text-sm text-zinc-500">{label ('noTags')}</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">{label ('comments')}</h2>
|
|
<form onSubmit={handleCommentSubmit}>
|
|
<input
|
|
className={inputClass ((fieldErrors.content ?? []).length > 0)}
|
|
type="text"
|
|
placeholder={message ('commentPlaceholder').text}
|
|
lang={message ('commentPlaceholder').language}
|
|
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={message ('deleteComment').text}
|
|
lang={message ('deleteComment').language}
|
|
onClick={async e => {
|
|
e.stopPropagation ()
|
|
|
|
if (!(await dialogue.confirm ({
|
|
title: label ('confirmDelete'),
|
|
description: (
|
|
<div className="my-3 w-120 rounded border border-black p-2
|
|
dark:border-white">
|
|
{commentBox (comment, commentProgramme)}
|
|
</div>),
|
|
confirmText: label ('delete'),
|
|
cancelText: label ('shared:actions.cancel'),
|
|
closeLabel: label ('settings:actions.close'),
|
|
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">{label ('participants')}</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">{label ('navigation:currentUser')}</span>)}
|
|
</div>))}
|
|
</div>
|
|
</section>)
|
|
|
|
const historyPanel = (
|
|
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
|
<h2 className="mb-3 font-bold">{label ('history')}</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">{label ('emptyHistory')}</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">
|
|
<LocalisedText language={locale}>
|
|
{localisedDateString (programme.createdAt, locale)}
|
|
</LocalisedText>
|
|
</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">{label ('weights')}</h2>
|
|
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
|
|
{label ('shared:actions.update')}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mx-4 grid gap-16 xl:grid-cols-3">
|
|
<div>
|
|
<h3 className="mb-2 text-sm font-bold">{label ('unlikelyTags')}</h3>
|
|
<div className="space-y-1 text-sm">
|
|
{weights.tagPenalties.length === 0
|
|
? <div className="text-zinc-500">{label ('emptyPenalties')}</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">{label ('unlikelyPosts')}</h3>
|
|
<WeightRows rows={weights.lightestPosts}/>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="mb-2 text-sm font-bold">{label ('likelyPosts')}</h3>
|
|
<WeightRows rows={weights.heaviestPosts}/>
|
|
</div>
|
|
</div>
|
|
</section>)
|
|
|
|
return (
|
|
<motion.div
|
|
layout={animationMode === 'off' ? false : 'position'}
|
|
transition={{ layout: layoutTransition }}
|
|
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">
|
|
<PageHead title={theatreTitle}>
|
|
<meta name="robots" content="noindex"/>
|
|
</PageHead>
|
|
|
|
<div
|
|
ref={sidebarContainerRef}
|
|
data-sidebar-container
|
|
className="flex min-h-full flex-col gap-4 overflow-visible md:h-full md:flex-row
|
|
md:overflow-hidden">
|
|
{layoutMode !== 'tagsBottom' && (
|
|
<SidebarComponent
|
|
sidebarKey="theatre-tags"
|
|
side="left"
|
|
maxWidth={leftSidebarMaxWidth}
|
|
onWidthChange={setLeftSidebarWidth}
|
|
className="hidden md:block">
|
|
{tagPanel}
|
|
</SidebarComponent>)}
|
|
|
|
<motion.main
|
|
layout={animationMode === 'off' ? false : 'position'}
|
|
className="order-1 min-w-0 flex-1 space-y-4 md:order-none
|
|
md:min-w-[360px] md:overflow-y-auto">
|
|
<div className="space-y-4">
|
|
<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">
|
|
<LocalisedText language={theatreTitle.language}>
|
|
{theatreTitle.text}
|
|
</LocalisedText>
|
|
</h1>
|
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
|
{label ('watching', { count: 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)}>
|
|
{label (LAYOUT_LABELS[mode])}
|
|
</Button>))}
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={skipVote.voted ? 'secondary' : 'destructive'}
|
|
disabled={loading || !(post)}
|
|
onClick={handleSkipVote}>
|
|
{skipVote.voted ? label ('cancelSkip') : label ('shared:actions.skip')}
|
|
{` ${ 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">
|
|
{label (loading ? 'selecting' : 'waiting')}
|
|
</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">
|
|
{label ('playing')}
|
|
</div>
|
|
{post ? (
|
|
<PrefetchLink
|
|
to={`/posts/${ post.id }`}
|
|
className="font-bold hover:underline">
|
|
{post.title || post.url}
|
|
</PrefetchLink>) : (
|
|
<span className="text-zinc-500">{label ('unselected')}</span>)}
|
|
</div>
|
|
|
|
{(post && canEditContent (user)) && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={!(post)}
|
|
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
|
|
{editingPost ? label ('close') : label ('shared:actions.edit')}
|
|
</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">{label ('editingPost')}</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>
|
|
{label ('editingSuffix')}
|
|
</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' && (
|
|
<SidebarComponent
|
|
sidebarKey="theatre-comments"
|
|
side="right"
|
|
maxWidth={rightSidebarMaxWidth}
|
|
onWidthChange={setRightSidebarWidth}
|
|
className="hidden md:block">
|
|
<div className="space-y-4">
|
|
{commentsPanel}
|
|
{participantsPanel}
|
|
</div>
|
|
</SidebarComponent>)}
|
|
</div>
|
|
</motion.div>)
|
|
}
|
|
|
|
|
|
const WeightRows: FC<{ rows: TheatrePostSelectionWeights['lightestPosts'] }> = ({ rows }) => {
|
|
const { label } = useUITranslation ('theatres')
|
|
return (
|
|
<div className="space-y-2 text-sm">
|
|
{rows.length === 0
|
|
? <div className="text-zinc-500">{label ('emptyPosts')}</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 flex-wrap justify-between gap-2 text-xs text-zinc-500">
|
|
<span>{label ('penalty', { value: row.penalty })}</span>
|
|
<span>{label ('weight', { value: row.weight.toFixed (3) })}</span>
|
|
</div>
|
|
</div>)))}
|
|
</div>)
|
|
}
|
|
|
|
|
|
export default TheatreDetailPage
|