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([]) 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(null) const deleteMutation = useMutation({ mutationFn: ({ id, withMedia, withSchedule, }: { id: string withMedia: boolean withSchedule: boolean }) => deleteShow(id, withMedia, withSchedule), onSuccess: () => { setToDelete(null) invalidate() }, onError, }) return (

{t('admin.shows.title')}

{ setPage(1) setQuery(e.target.value) }} />
{selected.length > 0 && setSelected([])} />}
{isLoading && ( )} {pageItems.map((show) => ( ))}
togglePage(e.target.checked)} /> {t('common.actions')}
{t('common.loading')}
toggleShow(show.id)} /> {show.name} {t(`admin.shows.kinds.${show.kind}`)} {/* В таблице показываем сам код MPAA: он короткий и одинаков во всех локалях, а расшифровка есть в селекте на карточке шоу. */} {show.audience ? ( {show.audience} ) : ( )} {show.seasonCount} {show.episodeCount}
{creating && setCreating(false)} onCreated={invalidate} />} {toDelete && ( deleteMutation.mutate({ id: toDelete.id, ...options })} onClose={() => setToDelete(null)} /> )}
) } /** Основной жанр шоу; остальные — под кнопкой «…», всплывающим списком. */ function GenreCell({ primary, others }: Readonly<{ primary: string | null; others: string[] }>) { const { t } = useTranslation() const buttonRef = useRef(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 if (others.length === 0) return {primary} const toggle = () => { if (at) { setAt(null) return } const rect = buttonRef.current?.getBoundingClientRect() if (rect) setAt({ left: rect.left, top: rect.bottom + 4 }) } return ( {primary} {at && (

{t('admin.shows.otherGenres')}

    {others.map((genre) => (
  • {genre}
  • ))}
)}
) }