このコミットが含まれているのは:
2026-10-18 02:45:26 +09:00
コミット 2d1a8b4730
17個のファイルの変更、488行の追加、326行の削除
+110 -96
ファイルの表示
@@ -8,13 +8,14 @@ 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 '@/components/dialogues/DialogueProvider'
import useDialogue from '@/lib/dialogues/useDialogue'
import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button'
import { SITE_TITLE } from '@/config'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { CATEGORIES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost, apiPut, isApiError } from '@/lib/api'
import { clientAnimationTransition,
clientScrollBehaviour } from '@/lib/clientAnimation'
@@ -27,8 +28,9 @@ import {
} from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { canEditContent } from '@/lib/users'
import { cn, dateString, inputClass } from '@/lib/utils'
import { cn, localisedDateString, inputClass } from '@/lib/utils'
import { useValidationErrors } from '@/lib/useValidationErrors'
import { useUITranslation } from '@/lib/useUITranslation'
import type { FC, FormEvent, ReactNode } from 'react'
@@ -65,48 +67,13 @@ const MIN_MAIN_WIDTH = 360
const SIDEBAR_GAP_WIDTH = 16
const LAYOUT_LABELS: Record<TheatreLayoutMode, string> = {
threeColumns: '3 列',
tagsBottom: '2 列 A 型',
commentsBottom: '2 列 B 型' }
threeColumns: 'layouts.threeColumns',
tagsBottom: 'layouts.tagsBottom',
commentsBottom: 'layouts.commentsBottom' }
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>)]
vertical: 'flows.vertical',
horizontal: 'flows.horizontal' }
const compareTagName = (a: Tag, b: Tag): number =>
@@ -176,6 +143,7 @@ const renderReadonlyTagTree = (
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)
@@ -200,7 +168,7 @@ const TagList: FC<{ tags: Tag[]; compact?: boolean; flow?: TagFlow }> = (
return (
<div key={cat}>
<div className="mb-1 shrink-0 text-xs font-bold text-zinc-500 dark:text-zinc-400">
{CATEGORY_NAMES[cat]}
{label (`categories.${cat}`)}
</div>
<ul className={cn ('space-y-1', compact && 'text-sm')}>
{rows.flatMap (tag =>
@@ -217,6 +185,36 @@ 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 (
@@ -644,7 +642,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
}
const skipVote = theatreInfo.skipVote
const theatreTitle = theatre?.name ? `上映会場『${ theatre.name }』` : '上映会場'
const theatreTitle = theatre?.name
? message ('namedTitle', { name: theatre.name })
: message ('title')
const postTags = post?.tags ?? []
const programmesAsc = useMemo (
() => [...programmes].sort (
@@ -693,7 +693,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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>
<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 => (
@@ -703,23 +703,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm"
variant={tagFlow === flow ? 'default' : 'outline'}
onClick={() => changeTagFlow (flow)}>
{TAG_FLOW_LABELS[flow]}
{label (TAG_FLOW_LABELS[flow])}
</Button>))}
</div>)}
</div>
{postTags.length === 0
? <div className="text-sm text-zinc-500">タグはありません。</div>
? <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">コメント</h2>
<h2 className="mb-3 font-bold">{label ('comments')}</h2>
<form onSubmit={handleCommentSubmit}>
<input
className={inputClass ((fieldErrors.content ?? []).length > 0)}
type="text"
placeholder="ここにコメントを入力"
placeholder={message ('commentPlaceholder').text}
lang={message ('commentPlaceholder').language}
value={content}
onChange={e => setContent (e.target.value)}
disabled={sending}/>
@@ -743,18 +744,21 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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="コメントを削除"
aria-label={message ('deleteComment').text}
lang={message ('deleteComment').language}
onClick={async e => {
e.stopPropagation ()
if (!(await dialogue.confirm ({
title: 'このコメントを削除しますか?',
title: label ('confirmDelete'),
description: (
<div className="my-3 w-120 rounded border border-black p-2
dark:border-white">
{commentBox (comment, commentProgramme)}
</div>),
confirmText: '削除',
confirmText: label ('delete'),
cancelText: label ('shared:actions.cancel'),
closeLabel: label ('settings:actions.close'),
variant: 'danger' })))
return
@@ -770,23 +774,24 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
const participantsPanel = (
<section className="rounded border-zinc-300 p-4 dark:border-zinc-800">
<h2 className="mb-3 font-bold">参加者</h2>
<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">お前</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">再生履歴</h2>
<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">まだ履歴はありません。</div>
? <div className="text-sm text-zinc-500">{label ('emptyHistory')}</div>
: (
programmes.map (programme => (
<div
@@ -799,7 +804,9 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
{programme.post.title || programme.post.url}
</PrefetchLink>
<div className="text-xs text-zinc-500">
{dateString (programme.createdAt)}
<LocalisedText language={locale}>
{localisedDateString (programme.createdAt, locale)}
</LocalisedText>
</div>
</div>)))}
</div>
@@ -808,18 +815,18 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
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>
<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">出にくいタグ</h3>
<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">まだ減点はありません。</div>
? <div className="text-zinc-500">{label ('emptyPenalties')}</div>
: (
weights.tagPenalties.slice (0, 12).map (row => (
<div
@@ -835,12 +842,12 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
</div>
<div>
<h3 className="mb-2 text-sm font-bold">出にくい候補</h3>
<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">出やすい候補</h3>
<h3 className="mb-2 text-sm font-bold">{label ('likelyPosts')}</h3>
<WeightRows rows={weights.heaviestPosts}/>
</div>
</div>
@@ -854,7 +861,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
md:overflow-hidden dark:bg-zinc-950 dark:text-zinc-50">
<Helmet>
<meta name="robots" content="noindex"/>
{theatre && <title>{`${ theatreTitle } | ${ SITE_TITLE }`}</title>}
{theatre && <title>{theatreTitle.text} | {SITE_TITLE}</title>}
</Helmet>
<div
@@ -882,9 +889,13 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<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>
<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">
同接 {theatreInfo.watchingUsers.length} 人
{label ('watching', { count: theatreInfo.watchingUsers.length })}
</p>
</div>
@@ -897,7 +908,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
size="sm"
variant={layoutMode === mode ? 'default' : 'outline'}
onClick={() => changeLayoutMode (mode)}>
{LAYOUT_LABELS[mode]}
{label (LAYOUT_LABELS[mode])}
</Button>))}
</div>
@@ -907,7 +918,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant={skipVote.voted ? 'secondary' : 'destructive'}
disabled={loading || !(post)}
onClick={handleSkipVote}>
{skipVote.voted ? 'スキップ取消' : 'スキップ'}
{skipVote.voted ? label ('cancelSkip') : label ('shared:actions.skip')}
{` ${ skipVote.votesCount } / ${ skipVote.requiredCount }`}
</Button>
@@ -924,14 +935,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
onPlaybackChange={syncPlaybackTime}
onError={handlePlaybackError}/>) : (
<div className="grid min-h-72 place-items-center text-zinc-400">
{loading ? '次の投稿を選んでゐます……' : '上映待機中'}
{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
@@ -939,7 +950,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
className="font-bold hover:underline">
{post.title || post.url}
</PrefetchLink>) : (
<span className="text-zinc-500">未選択</span>)}
<span className="text-zinc-500">{label ('unselected')}</span>)}
</div>
{(post && canEditContent (user)) && (
@@ -949,7 +960,7 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
variant="outline"
disabled={!(post)}
onClick={() => post && setEditingPost (ep => ep ? null : post)}>
{editingPost ? '閉じる' : '編輯'}
{editingPost ? label ('close') : label ('shared:actions.edit')}
</Button>)}
</div>
</section>
@@ -958,14 +969,14 @@ const TheatreDetailPage: FC<Props> = ({ user }: Props) => {
<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>
<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>
@@ -1028,27 +1039,30 @@ 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-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>)
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