import { useQuery } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { qk } from '@/shared/api/query-keys' import type { MediaAssetDto, ShowDto } 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { Pager } from '@/shared/ui/pager' import { toast } from '@/shared/ui/toast-store' import { listAllMedia } from '@/features/admin/media/api' import { type ParsedEpisode, compareParsed, formatSeasonEpisode, parseEpisodeName, } from '@/features/admin/media/episode-parse' import { addEpisode } from './api' type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } const PAGE_SIZE = 20 /** Пока идёт пакетное добавление — прогресс вместо подписи; серии добавляются по одной. */ function addButtonLabel( progress: { current: number; total: number } | null, count: number, t: ReturnType['t'], ) { if (progress) return `${progress.current}/${progress.total}` return `${t('admin.shows.addSelected')} (${count})` } /** * Выбор готовых медиа для добавления в шоу. Живёт отдельным окном: список кандидатов — это вся * необработанная библиотека, и на странице шоу он заслонял собой и метаданные, и сами серии. * Заодно тяжёлый запрос за готовыми ассетами уходит только при открытии окна. */ export function AddEpisodesDialog({ show, onClose, onAdded, }: Readonly<{ show: ShowDto; onClose: () => void; onAdded: () => void }>) { const { t } = useTranslation() const onError = useApiError() const [filter, setFilter] = useState('') const [deselected, setDeselected] = useState>(new Set()) const [adding, setAdding] = useState<{ current: number; total: number } | null>(null) const [page, setPage] = useState(1) const { data: ready, isLoading } = useQuery({ queryKey: qk.media.ready, queryFn: () => listAllMedia({ statuses: ['Ready'] }), }) // Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные // по распознанному номеру сезона/серии (нераспознанные — в конец по имени). const candidates = useMemo(() => { const existing = new Set(show.episodes.map((e) => e.mediaAssetId)) const term = filter.trim().toLowerCase() return (ready?.items ?? []) .filter((a) => !existing.has(a.id)) .filter((a) => !term || a.originalFileName.toLowerCase().includes(term)) .map((asset) => ({ asset, parsed: parseEpisodeName(asset.originalFileName) })) .sort((a, b) => compareParsed( { name: a.asset.originalFileName, parsed: a.parsed }, { name: b.asset.originalFileName, parsed: b.parsed }, ), ) }, [show, ready, filter]) const isSingle = show.kind === 'Single' const selected = candidates.filter((c) => !deselected.has(c.asset.id)) // Страницу зажимаем в допустимый диапазон — чтобы после фильтрации не застрять на пустой. const totalPages = Math.max(1, Math.ceil(candidates.length / PAGE_SIZE)) const pageSafe = Math.min(page, totalPages) const items = candidates.slice((pageSafe - 1) * PAGE_SIZE, pageSafe * PAGE_SIZE) const toggle = (id: string) => setDeselected((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) const bulkAdd = async () => { // Полнометражке — максимум одна серия. const chosen = isSingle ? selected.slice(0, 1) : selected let added = 0 for (let i = 0; i < chosen.length; i++) { setAdding({ current: i + 1, total: chosen.length }) try { await addEpisode(show.id, chosen[i].asset.id) added++ } catch (error) { onError(error) } } setAdding(null) setDeselected(new Set()) onAdded() if (added > 0) { toast.success(t('admin.shows.addedCount', { count: added })) onClose() } } return ( !open && onClose()}> {t('admin.shows.addMedia')} {t('admin.shows.addMediaHint')}
{ setPage(1) setFilter(e.target.value) }} />
{isLoading && (

{t('common.loading')}

)} {!isLoading && candidates.length === 0 && (

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

)} {candidates.length > 0 && (
    {items.map(({ asset, parsed }) => { const label = formatSeasonEpisode(parsed) return (
  • ) })}
)}
{ready?.truncated && (

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

)}
) }