Files
TeleWave/frontend/src/features/admin/shows/ShowsPanel.tsx
T
Leonid Pershin 281d081e6b
ci / build-backend (push) Successful in 2m23s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 2m18s
ci / sonar (push) Successful in 5m51s
Refactor admin panels to utilize CreateNameDialog for creating new entities
Updated the BumpersPanel, ChannelsPanel, CollectionsPanel, GroupsPanel, ClipGroupPanel, and ShowsPanel components to replace direct input fields with a CreateNameDialog for creating new items. This change enhances user experience by centralizing the creation process and improving UI consistency. Localization strings were also updated to reflect new dialog titles and labels in both English and Russian.
2026-07-29 23:26:32 +03:00

358 lines
14 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { Plus } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { ShowKind, ShowSummaryDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { listGenres } from '@/features/admin/genres/api'
import { deleteShow, listShows } from './api'
import { CreateShowDialog } from './CreateShowDialog'
import { DeleteShowDialog } from './DeleteShowDialog'
import { BulkTagBar } from './BulkTagBar'
const PAGE_SIZE = 20
export function ShowsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [creating, setCreating] = useState(false)
const [query, setQuery] = useState('')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('name', false)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
const [genreFilter, setGenreFilter] = useState('all')
// Фильтр по типу — клиентский: тип уже приехал в списке, и гонять за ним запрос незачем.
const [kindFilter, setKindFilter] = useState<'all' | ShowKind>('all')
// Отмеченные для массовых действий. Живут отдельно от фильтров: сузил список — выбор не потерялся.
const [selected, setSelected] = useState<string[]>([])
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const { data, isLoading } = useQuery({
queryKey: qk.shows.byGenre(genreFilter),
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
})
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
const all = data ?? []
const matched = q
? all.filter(
(s) =>
s.name.toLowerCase().includes(q) || (s.originalName ?? '').toLowerCase().includes(q),
)
: all
const byKind = kindFilter === 'all' ? matched : matched.filter((s) => s.kind === kindFilter)
return sortRows(byKind, sort, {
name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind,
// Без рейтинга — в начало: пустое значение сортируется раньше любого кода.
audience: (s) => s.audience ?? '',
genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount,
})
}, [data, query, kindFilter, sort])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
const onError = useApiError()
// Отметки страницей и по одной. Вынесены из разметки: три вложенных стрелки прямо в onChange
// читаются хуже, чем имя действия.
const pageAllSelected =
pageItems.length > 0 && pageItems.every((show) => selected.includes(show.id))
const togglePage = (checked: boolean) =>
setSelected((current) => {
const ids = pageItems.map((show) => show.id)
return checked
? [...new Set([...current, ...ids])]
: current.filter((id) => !ids.includes(id))
})
const toggleShow = (showId: string) =>
setSelected((current) =>
current.includes(showId) ? current.filter((id) => id !== showId) : [...current, showId],
)
// Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем.
const [toDelete, setToDelete] = useState<ShowSummaryDto | null>(null)
const deleteMutation = useMutation({
mutationFn: ({
id,
withMedia,
withSchedule,
}: {
id: string
withMedia: boolean
withSchedule: boolean
}) => deleteShow(id, withMedia, withSchedule),
onSuccess: () => {
setToDelete(null)
invalidate()
},
onError,
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
<Button size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
{t('common.create')}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => {
setPage(1)
setQuery(e.target.value)
}}
/>
<Select
value={kindFilter}
onValueChange={(v) => {
setPage(1)
setKindFilter(v as 'all' | ShowKind)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.shows.allKinds')}</SelectItem>
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent>
</Select>
<Select
value={genreFilter}
onValueChange={(v) => {
setPage(1)
setGenreFilter(v)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.shows.allGenres')}</SelectItem>
{(genres ?? []).map((genre) => (
<SelectItem key={genre.id} value={genre.id}>
{genre.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selected.length > 0 && <BulkTagBar showIds={selected} onDone={() => setSelected([])} />}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="w-8 px-4 py-2">
<input
type="checkbox"
aria-label={t('admin.shows.bulk.selectPage')}
checked={pageAllSelected}
onChange={(e) => togglePage(e.target.checked)}
/>
</th>
<SortHeader
label={t('admin.shows.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.kind')}
sortKey="kind"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.audience')}
sortKey="audience"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.genre')}
sortKey="genre"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.seasons')}
sortKey="seasons"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.episodes')}
sortKey="episodes"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={8}>
{t('common.loading')}
</td>
</tr>
)}
{pageItems.map((show) => (
<tr key={show.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<input
type="checkbox"
aria-label={show.name}
checked={selected.includes(show.id)}
onChange={() => toggleShow(show.id)}
/>
</td>
<td className="px-4 py-2">
<Link
to="/admin/shows/$showId"
params={{ showId: show.id }}
className="text-primary hover:underline"
>
{show.name}
</Link>
</td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td>
<td className="px-4 py-2">
{/* В таблице показываем сам код MPAA: он короткий и одинаков во всех локалях,
а расшифровка есть в селекте на карточке шоу. */}
{show.audience ? (
<Badge variant="muted">{show.audience}</Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-2 text-muted-foreground">
<GenreCell primary={show.primaryGenre} others={show.otherGenres} />
</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={() => setToDelete(show)}>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pager page={page} totalPages={totalPages} onChange={setPage} />
{creating && <CreateShowDialog onClose={() => setCreating(false)} onCreated={invalidate} />}
{toDelete && (
<DeleteShowDialog
show={toDelete}
pending={deleteMutation.isPending}
onConfirm={(options) => deleteMutation.mutate({ id: toDelete.id, ...options })}
onClose={() => setToDelete(null)}
/>
)}
</div>
)
}
/** Основной жанр шоу; остальные — под кнопкой «…», всплывающим списком. */
function GenreCell({ primary, others }: Readonly<{ primary: string | null; others: string[] }>) {
const { t } = useTranslation()
const buttonRef = useRef<HTMLButtonElement>(null)
// Всплывашку позиционируем fixed по кнопке: таблица лежит в контейнере с overflow-x-auto,
// и absolute-список он обрезал бы по нижней границе. Прокрутка/ресайз сдвинули бы её мимо
// кнопки — на них просто закрываем.
const [at, setAt] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
if (at == null) return
const close = () => setAt(null)
window.addEventListener('scroll', close, true)
window.addEventListener('resize', close)
return () => {
window.removeEventListener('scroll', close, true)
window.removeEventListener('resize', close)
}
}, [at])
if (!primary) return <span></span>
if (others.length === 0) return <span>{primary}</span>
const toggle = () => {
if (at) {
setAt(null)
return
}
const rect = buttonRef.current?.getBoundingClientRect()
if (rect) setAt({ left: rect.left, top: rect.bottom + 4 })
}
return (
<span className="inline-flex items-center gap-1.5">
{primary}
<button
ref={buttonRef}
type="button"
title={t('admin.shows.otherGenres')}
aria-label={t('admin.shows.otherGenres')}
aria-expanded={at != null}
className="rounded-sm border border-border px-1 text-xs leading-tight hover:border-primary/60 hover:text-foreground"
onClick={toggle}
onBlur={() => setAt(null)}
onKeyDown={(e) => e.key === 'Escape' && setAt(null)}
>
</button>
{at && (
<div
className="crt-popover fixed z-50 max-w-56 rounded-md px-2 py-1.5 text-xs"
style={{ left: at.left, top: at.top }}
>
<p className="mb-1 text-[10px] uppercase tracking-wide text-muted-foreground">
{t('admin.shows.otherGenres')}
</p>
<ul className="flex flex-col gap-0.5 text-foreground">
{others.map((genre) => (
<li key={genre}>{genre}</li>
))}
</ul>
</div>
)}
</span>
)
}