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.
358 lines
14 KiB
TypeScript
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>
|
|
)
|
|
}
|