このコミットが含まれているのは:
+64
-16
@@ -4,7 +4,8 @@
|
||||
|
||||
These rules apply to work under `frontend/`.
|
||||
|
||||
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS, Framer Motion, Radix UI-style components, MDX, and Zustand.
|
||||
This is a Vite + React + TypeScript app using TanStack Query, Tailwind CSS,
|
||||
Framer Motion, Radix UI-style components, MDX, and Zustand.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -17,9 +18,11 @@ npm run lint
|
||||
npm run preview
|
||||
```
|
||||
|
||||
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs `node scripts/generate-sitemap.js`.
|
||||
`npm run build` runs `tsc -b && vite build`, and `postbuild` runs
|
||||
`node scripts/generate-sitemap.js`.
|
||||
|
||||
There is currently no `test` script in `package.json`. Do not run or report `npm test` unless a test script is added.
|
||||
There is currently no `test` script in `package.json`. Do not run or report
|
||||
`npm test` unless a test script is added.
|
||||
|
||||
After frontend changes, run:
|
||||
|
||||
@@ -32,18 +35,33 @@ If either command cannot be run or fails, report the exact command and failure.
|
||||
|
||||
## TypeScript
|
||||
|
||||
- TypeScript is strict. `tsconfig.app.json` enables `strict`, `noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`, `noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
|
||||
- TypeScript is strict. `tsconfig.app.json` enables `strict`,
|
||||
`noUnusedLocals`, `noUnusedParameters`, `erasableSyntaxOnly`,
|
||||
`noFallthroughCasesInSwitch`, and `noUncheckedSideEffectImports`.
|
||||
- Keep types explicit at module boundaries, API helpers, and exported utilities.
|
||||
- Use `import type` for type-only imports.
|
||||
- Prefer existing shared types from `src/types.ts` before adding local duplicate types.
|
||||
- Preserve the repository's existing spacing style in TypeScript, including GNU-style spacing before call parentheses where it is already used.
|
||||
- Preserve the repository's existing spacing style in TypeScript, including
|
||||
GNU-style spacing before call parentheses where it is already used.
|
||||
- Prefer single quotes for strings unless interpolation or escaping makes double quotes better.
|
||||
- Never write a TypeScript or TSX line longer than 99 characters.
|
||||
- Aim to keep TypeScript and TSX lines within 79 characters where practical.
|
||||
- Use 4-space logical indentation in TypeScript and TSX.
|
||||
- In TypeScript and TSX only, replace every leading run of 8 spaces with a tab
|
||||
to reduce bytes.
|
||||
- Treat one leading tab as exactly equivalent to 8 leading spaces.
|
||||
- Use tabs only for leading indentation. Never replace spaces that occur after
|
||||
a non-space character on the same line.
|
||||
|
||||
## React
|
||||
|
||||
- Use function components.
|
||||
- Existing page components commonly export an anonymous function satisfying `FC`; match nearby file style when editing.
|
||||
- Existing page components commonly export an anonymous function satisfying
|
||||
`FC`; match nearby file style when editing.
|
||||
- React hooks must be called unconditionally and at the top level of components or custom hooks.
|
||||
- Gate editing and other privileged controls with shared permission helpers
|
||||
such as `canEditContent`, instead of showing controls and relying only on a
|
||||
later API failure.
|
||||
- Keep page-level components under `src/pages`.
|
||||
- Keep shared and feature components under `src/components`.
|
||||
- Use `react-router-dom` route params and navigation patterns already present in `src/App.tsx`.
|
||||
@@ -52,17 +70,23 @@ If either command cannot be run or fails, report the exact command and failure.
|
||||
## TanStack Query
|
||||
|
||||
- Use `@tanstack/react-query` for server state.
|
||||
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there instead of using ad hoc arrays in components.
|
||||
- Fetch functions should live in domain helpers under `src/lib`, such as `posts.ts`, `tags.ts`, or `wiki.ts`.
|
||||
- Use `useQueryClient().invalidateQueries` with the shared root keys when mutations affect cached lists or detail views.
|
||||
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create additional clients in feature code.
|
||||
- Query keys should come from `src/lib/queryKeys.ts`; add key builders there
|
||||
instead of using ad hoc arrays in components.
|
||||
- Fetch functions should live in domain helpers under `src/lib`, such as
|
||||
`posts.ts`, `tags.ts`, or `wiki.ts`.
|
||||
- Use `useQueryClient().invalidateQueries` with the shared root keys when
|
||||
mutations affect cached lists or detail views.
|
||||
- The app-wide `QueryClient` is configured in `src/main.tsx`; do not create
|
||||
additional clients in feature code.
|
||||
|
||||
## API calls
|
||||
|
||||
- Use `src/lib/api.ts` for HTTP calls.
|
||||
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts non-blob responses to camelCase.
|
||||
- The API wrapper attaches `X-Transfer-Code` from `localStorage` and converts
|
||||
non-blob responses to camelCase.
|
||||
- Send Rails snake_case params and request body keys where the backend expects them.
|
||||
- Do not bypass the API wrapper unless there is a specific reason, such as a third-party request outside the Rails API.
|
||||
- Do not bypass the API wrapper unless there is a specific reason, such as a
|
||||
third-party request outside the Rails API.
|
||||
- For blob responses, pass `responseType: 'blob'` so the wrapper does not camelCase the body.
|
||||
|
||||
## Imports and aliases
|
||||
@@ -76,17 +100,41 @@ If either command cannot be run or fails, report the exact command and failure.
|
||||
|
||||
- Tailwind scans `src/**/*.{html,js,ts,jsx,tsx,mdx}`.
|
||||
- Use `cn` from `src/lib/utils.ts` for conditional class names and class merging.
|
||||
- Reuse components from `src/components/common`, `src/components/layout`, and `src/components/ui` before adding new primitives.
|
||||
- Reuse components from `src/components/common`, `src/components/layout`, and
|
||||
`src/components/ui` before adding new primitives.
|
||||
- Keep Tailwind classes consistent with nearby components.
|
||||
- When adding dynamic tag color classes, update `tailwind.config.js` safelist if the class cannot be statically detected.
|
||||
- Prefer restrained, content-first UI chrome: avoid adding card backgrounds,
|
||||
heavy borders, or nested panel decoration unless the surrounding screen
|
||||
already uses them.
|
||||
- Keep operational screens dense and direct; trim explanatory copy and use
|
||||
short Japanese labels that fit the control.
|
||||
- Preserve existing Japanese tone and orthography in nearby UI text, including
|
||||
old-kana wording where the file already uses it.
|
||||
- When adding dynamic tag color classes, update `tailwind.config.js` safelist
|
||||
if the class cannot be statically detected.
|
||||
- Do not introduce new UI libraries or production dependencies without approval.
|
||||
|
||||
## TSX formatting
|
||||
|
||||
- Preserve compact TSX expression shapes such as inline ternary branches and
|
||||
closing `</div>)` forms when nearby code uses them.
|
||||
- For long Tailwind `className` strings, wrap across lines only when needed.
|
||||
- Keep continuation indentation aligned with the 4-space logical indentation
|
||||
rule, using tabs only as leading 8-space compression.
|
||||
- Do not add braces around `if`, `else`, or `for` bodies when the body is a
|
||||
single physical line.
|
||||
- Always add braces around `if`, `else`, or `for` bodies when the body spans
|
||||
two or more physical lines, even if it is one statement.
|
||||
- Avoid reformatting unrelated JSX.
|
||||
|
||||
## Lint and build constraints
|
||||
|
||||
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`, and `eslint-plugin-react-refresh`.
|
||||
- ESLint uses `@eslint/js`, `typescript-eslint`, `eslint-plugin-react-hooks`,
|
||||
and `eslint-plugin-react-refresh`.
|
||||
- The hooks rules are enforced; fix hook ordering instead of disabling the rule.
|
||||
- `react-refresh/only-export-components` is enabled as a warning with `allowConstantExport`.
|
||||
- Build failures from unused locals or unused parameters are TypeScript errors, not lint-only issues.
|
||||
- Build failures from unused locals or unused parameters are TypeScript
|
||||
errors, not lint-only issues.
|
||||
|
||||
## Files to avoid in routine work
|
||||
|
||||
|
||||
@@ -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>)
|
||||
|
||||
|
||||
|
||||
新しい課題から参照
ユーザをブロックする