このコミットが含まれているのは:
@@ -2,6 +2,7 @@ import type { User, UserRole } from '@/types'
|
||||
|
||||
const CONTENT_EDITOR_ROLES: readonly UserRole[] = ['admin', 'member']
|
||||
|
||||
|
||||
export const canEditContent = (
|
||||
user: Pick<User, 'role'> | null | undefined,
|
||||
): boolean => user != null && CONTENT_EDITOR_ROLES.includes (user.role)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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'
|
||||
|
||||
@@ -42,11 +43,10 @@ const INITIAL_THEATRE_INFO: TheatreInfo =
|
||||
postStartedAt: null,
|
||||
postElapsedMs: null,
|
||||
watchingUsers: [],
|
||||
skipVote: {
|
||||
votesCount: 0,
|
||||
requiredCount: 1,
|
||||
watchingUsersCount: 0,
|
||||
voted: false } }
|
||||
skipVote: { votesCount: 0,
|
||||
requiredCount: 1,
|
||||
watchingUsersCount: 0,
|
||||
voted: false } }
|
||||
|
||||
const INITIAL_WEIGHTS: TheatrePostSelectionWeights =
|
||||
{ tagPenalties: [], lightestPosts: [], heaviestPosts: [] }
|
||||
@@ -56,12 +56,12 @@ const TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
|
||||
|
||||
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
|
||||
threeColumns: '3 列',
|
||||
tagsBottom: '2 列(コメント欄)',
|
||||
commentsBottom: '2 列(タグ欄)' }
|
||||
tagsBottom: '2 列 A 型',
|
||||
commentsBottom: '2 列 B 型' }
|
||||
|
||||
const TAG_FLOW_LABELS: Record<TagFlow, string> = {
|
||||
vertical: 'タグ縦',
|
||||
horizontal: 'タグ横' }
|
||||
vertical: '縦並び',
|
||||
horizontal: '横並び' }
|
||||
|
||||
|
||||
const userName = (user: Pick<User, 'id' | 'name'> | null | undefined): string =>
|
||||
@@ -88,13 +88,13 @@ const commentBox = (
|
||||
</div>),
|
||||
(
|
||||
<div key={`${ comment.no }-post`} className="mt-1 w-full text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{programme ? (
|
||||
{programme && (
|
||||
<>
|
||||
この時の動画:
|
||||
<PrefetchLink to={`/posts/${ programme.post.id }`} className="font-bold hover:underline">
|
||||
{programme.post.title || programme.post.url}
|
||||
</PrefetchLink>
|
||||
</>) : 'この時の動画:履歴外'}
|
||||
 へのコメント
|
||||
</>)}
|
||||
</div>)]
|
||||
|
||||
|
||||
@@ -124,13 +124,15 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||
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 (
|
||||
<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">
|
||||
@@ -144,10 +146,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
|
||||
<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')}>
|
||||
<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}/>
|
||||
@@ -188,16 +187,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
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'
|
||||
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'
|
||||
return (
|
||||
(['vertical', 'horizontal'] as TagFlow[]).includes (stored as TagFlow)
|
||||
? (stored as TagFlow)
|
||||
: 'vertical')
|
||||
})
|
||||
const { fieldErrors, clearValidationErrors, applyValidationError } =
|
||||
useValidationErrors<TheatreCommentField> ()
|
||||
@@ -217,14 +218,15 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
setTheatreInfo (nextInfo)
|
||||
}, [])
|
||||
|
||||
const currentPostElapsedMs = useCallback ((info: TheatreInfo = theatreInfoRef.current): number => {
|
||||
if (info.postElapsedMs == null)
|
||||
return 0
|
||||
const currentPostElapsedMs = useCallback (
|
||||
(info: TheatreInfo = theatreInfoRef.current): number => {
|
||||
if (info.postElapsedMs == null)
|
||||
return 0
|
||||
|
||||
return Math.max (
|
||||
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
|
||||
0)
|
||||
}, [])
|
||||
return Math.max (
|
||||
info.postElapsedMs + performance.now () - theatreInfoReceivedAtRef.current,
|
||||
0)
|
||||
}, [])
|
||||
|
||||
const refreshProgrammes = useCallback (async () => {
|
||||
if (!(id))
|
||||
@@ -352,11 +354,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (ended)
|
||||
{
|
||||
if (!(cancelled))
|
||||
setTheatreInfo (prev => ({
|
||||
...prev,
|
||||
postId: null,
|
||||
postStartedAt: null,
|
||||
postElapsedMs: null }))
|
||||
{
|
||||
setTheatreInfo (prev => ({
|
||||
...prev,
|
||||
postId: null,
|
||||
postStartedAt: null,
|
||||
postElapsedMs: null }))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -561,7 +565,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
return <ErrorScreen status={status}/>
|
||||
|
||||
const tagPanel = (
|
||||
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<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' && (
|
||||
@@ -583,7 +587,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
</section>)
|
||||
|
||||
const commentsPanel = (
|
||||
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||||
<h2 className="mb-3 font-bold">コメント</h2>
|
||||
<form onSubmit={handleCommentSubmit}>
|
||||
<input
|
||||
@@ -598,18 +602,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
|
||||
<div
|
||||
ref={commentsRef}
|
||||
className="mt-3 max-h-[48vh] overflow-y-auto rounded border border-zinc-200 dark:border-zinc-800">
|
||||
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">
|
||||
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"
|
||||
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 ()
|
||||
@@ -617,7 +624,8 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
if (!(await dialogue.confirm ({
|
||||
title: 'このコメントを削除しますか?',
|
||||
description: (
|
||||
<div className="my-3 w-64 rounded border border-black p-2 dark:border-white">
|
||||
<div className="my-3 w-120 rounded border border-black p-2
|
||||
dark:border-white">
|
||||
{commentBox (comment, commentProgramme)}
|
||||
</div>),
|
||||
confirmText: '削除',
|
||||
@@ -635,7 +643,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
</section>)
|
||||
|
||||
const participantsPanel = (
|
||||
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<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 => (
|
||||
@@ -647,43 +655,56 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
</section>)
|
||||
|
||||
const historyPanel = (
|
||||
<section className="rounded border border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
|
||||
<h2 className="mb-3 font-bold">再生履歴</h2>
|
||||
<div className="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-t border-zinc-100 py-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">
|
||||
#{programme.position} / {dateString (programme.createdAt)}
|
||||
</div>
|
||||
</div>))}
|
||||
<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 border-zinc-300 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<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>
|
||||
<h2 className="font-bold">抽選重み</h2>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => void refreshWeights ()}>
|
||||
更新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
<div className="mx-4 grid gap-16 xl:grid-cols-3">
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-bold">下がってゐるタグ</h3>
|
||||
<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>))}
|
||||
{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>
|
||||
|
||||
@@ -710,14 +731,19 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
</Helmet>
|
||||
|
||||
<div className={cn (
|
||||
'grid min-h-full gap-4 overflow-visible p-3 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)]')}>
|
||||
'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]">
|
||||
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>
|
||||
@@ -725,145 +751,143 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
|
||||
<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 (layoutMode === 'tagsBottom' && 'md:[direction:ltr]')}>
|
||||
<section className="overflow-hidden rounded border border-zinc-300 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b 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>))}
|
||||
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>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={skipVote.voted ? 'secondary' : 'destructive'}
|
||||
disabled={loading || !(post)}
|
||||
onClick={handleSkipVote}>
|
||||
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
|
||||
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={skipVote.voted ? 'secondary' : 'destructive'}
|
||||
disabled={loading || !(post)}
|
||||
onClick={handleSkipVote}>
|
||||
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
|
||||
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
|
||||
</Button>
|
||||
|
||||
<div className="flex justify-center bg-black">
|
||||
{post ? (
|
||||
<PostEmbed
|
||||
key={post.id}
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={syncPlayback}
|
||||
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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center bg-black mx-4">
|
||||
{post ? (
|
||||
<PrefetchLink to={`/posts/${ post.id }`} className="font-bold hover:underline">
|
||||
{post.title || post.url}
|
||||
</PrefetchLink>) : (
|
||||
<span className="text-zinc-500">未選択</span>)}
|
||||
<PostEmbed
|
||||
key={post.id}
|
||||
ref={embedRef}
|
||||
post={post}
|
||||
onLoadComplete={info => {
|
||||
embedRef.current?.play ()
|
||||
setVideoLength (info.lengthInSeconds * 1_000)
|
||||
}}
|
||||
onMetadataChange={syncPlayback}
|
||||
onError={handlePlaybackError}/>) : (
|
||||
<div className="grid min-h-72 place-items-center text-zinc-400">
|
||||
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
|
||||
</div>)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!(post)}
|
||||
onClick={() => post && setEditingPost (post)}>
|
||||
この投稿を編集
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
{editingPost && (
|
||||
<section className="rounded border border-amber-300 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-bold">編集中の投稿</h2>
|
||||
{(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>
|
||||
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setEditingPost (null)}>
|
||||
閉じる
|
||||
</Button>
|
||||
</div>
|
||||
<PostEditForm
|
||||
post={editingPost}
|
||||
onSave={newPost => {
|
||||
setEditingPost (newPost)
|
||||
if (post?.id === newPost.id)
|
||||
setPost (newPost)
|
||||
void refreshWeights ()
|
||||
}}/>
|
||||
</section>)}
|
||||
|
||||
<PostEditForm
|
||||
post={editingPost}
|
||||
onSave={newPost => {
|
||||
setEditingPost (newPost)
|
||||
if (post?.id === newPost.id)
|
||||
setPost (newPost)
|
||||
void refreshWeights ()
|
||||
}}/>
|
||||
</section>)}
|
||||
<div className="md:hidden">
|
||||
{commentsPanel}
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
{commentsPanel}
|
||||
</div>
|
||||
{layoutMode === 'commentsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{commentsPanel}
|
||||
</div>)}
|
||||
|
||||
{layoutMode === 'commentsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{commentsPanel}
|
||||
</div>)}
|
||||
<div className="md:hidden">
|
||||
{tagPanel}
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
{tagPanel}
|
||||
</div>
|
||||
{layoutMode === 'tagsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{tagPanel}
|
||||
</div>)}
|
||||
|
||||
{layoutMode === 'tagsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{tagPanel}
|
||||
</div>)}
|
||||
{historyPanel}
|
||||
{weightsPanel}
|
||||
|
||||
{historyPanel}
|
||||
{weightsPanel}
|
||||
<div className="md:hidden">
|
||||
{participantsPanel}
|
||||
</div>
|
||||
|
||||
<div className="md:hidden">
|
||||
{participantsPanel}
|
||||
</div>
|
||||
|
||||
{layoutMode === 'commentsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{participantsPanel}
|
||||
</div>)}
|
||||
{layoutMode === 'commentsBottom' && (
|
||||
<div className="hidden md:block">
|
||||
{participantsPanel}
|
||||
</div>)}
|
||||
</div>
|
||||
</motion.main>
|
||||
|
||||
@@ -881,17 +905,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
|
||||
|
||||
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-t 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>))}
|
||||
{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>)
|
||||
|
||||
|
||||
|
||||
新しい課題から参照
ユーザをブロックする