このコミットが含まれているのは:
2026-06-07 00:05:18 +09:00
コミット 364d154b6a
8個のファイルの変更482行の追加283行の削除
+235 -204
ファイルの表示
@@ -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>
</>) : 'この時の動画:履歴外'}
&thinsp;
</>)}
</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>)