ファイル
btrc-hub/frontend/src/pages/theatres/TheatreDetailPage.tsx
T
2026-06-07 02:01:16 +09:00

956 行
26 KiB
TypeScript
Raw Blame 履歴

このファイルには曖昧(ambiguous)なUnicode文字が含まれてゐます
このファイルには,他の文字と見間違える可能性があるUnicode文字が含まれてゐます. それが意図的なものと考えられる場合は,この警告を無視して構ゐません. それらの文字を表示するにはエスケープボタンを使用します.
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>
&thinsp;
</>)}
</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)
}}>
&times;
</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