diff --git a/frontend/src/features/admin/shows/AddEpisodesDialog.tsx b/frontend/src/features/admin/shows/AddEpisodesDialog.tsx new file mode 100644 index 0000000..af539fc --- /dev/null +++ b/frontend/src/features/admin/shows/AddEpisodesDialog.tsx @@ -0,0 +1,202 @@ +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')}

+ )} + +
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index 6a8420b..30dc587 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -2,66 +2,34 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronLeft } from 'lucide-react' +import { ChevronLeft, Plus } from 'lucide-react' import { qk } from '@/shared/api/query-keys' -import { - AUDIENCE_UNSET, - SHOW_AUDIENCES, - type MediaAssetDto, - type ShowAudience, -} from '@/shared/api/types' +import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } 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 { 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 { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse' import { formatDuration } from '@/features/admin/media/format' +import { AddEpisodesDialog } from './AddEpisodesDialog' import { ShowGenresField } from './ShowGenresField' import { ShowMetadataCard } from './ShowMetadataCard' import { imageUrl } from '@/features/admin/images/api' -import { addEpisode, getShow, removeEpisode, setShowAudience } from './api' - -type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } +import { getShow, removeEpisode, setShowAudience } from './api' 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 ShowDetail({ showId }: Readonly<{ showId: string }>) { const { t } = useTranslation() const queryClient = useQueryClient() - const [filter, setFilter] = useState('') - const [deselected, setDeselected] = useState>(new Set()) - const [adding, setAdding] = useState<{ current: number; total: number } | null>(null) - const [candPage, setCandPage] = useState(1) + const [addOpen, setAddOpen] = useState(false) const [epPage, setEpPage] = useState(1) const { data: show, isLoading } = useQuery({ queryKey: qk.shows.detail(showId), queryFn: () => getShow(showId), }) - const { data: ready } = useQuery({ - queryKey: qk.media.ready, - queryFn: () => listAllMedia({ statuses: ['Ready'] }), - }) - const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) }) const onError = useApiError() @@ -77,26 +45,6 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { onError, }) - // Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные - // по распознанному номеру сезона/серии (нераспознанные — в конец по имени). - const candidates = useMemo(() => { - if (!show) return [] - 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 selected = candidates.filter((c) => !deselected.has(c.asset.id)) - // Какие сезоны загружены — по распознанным SxxExx в именах серий (отсортированы по возрастанию). const seasons = useMemo(() => { if (!show) return [] as number[] @@ -110,44 +58,14 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { if (isLoading || !show) return

{t('common.loading')}

- const isSingle = show.kind === 'Single' - const canAdd = !isSingle || show.episodes.length === 0 + // Полнометражке добавлять нечего, пока её единственная серия уже на месте. + const canAdd = show.kind !== 'Single' || show.episodes.length === 0 - // Постраничный вывод длинных списков (кандидаты на добавление и сами серии). Страницу зажимаем в - // допустимый диапазон — чтобы после удаления/фильтрации не застрять на пустой странице. - const candTotalPages = Math.max(1, Math.ceil(candidates.length / PAGE_SIZE)) - const candPageSafe = Math.min(candPage, candTotalPages) - const candItems = candidates.slice((candPageSafe - 1) * PAGE_SIZE, candPageSafe * PAGE_SIZE) + // Страницу серий зажимаем в допустимый диапазон — чтобы после удаления не застрять на пустой. const epTotalPages = Math.max(1, Math.ceil(show.episodes.length / PAGE_SIZE)) const epPageSafe = Math.min(epPage, epTotalPages) const epOffset = (epPageSafe - 1) * PAGE_SIZE const epItems = show.episodes.slice(epOffset, epOffset + 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 items = isSingle ? selected.slice(0, 1) : selected - let added = 0 - for (let i = 0; i < items.length; i++) { - setAdding({ current: i + 1, total: items.length }) - try { - await addEpisode(showId, items[i].asset.id) - added++ - } catch (error) { - onError(error) - } - } - setAdding(null) - setDeselected(new Set()) - void invalidate() - if (added > 0) toast.success(t('admin.shows.addedCount', { count: added })) - } return (
@@ -220,69 +138,18 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) { - {canAdd && ( -
-
- { - setCandPage(1) - setFilter(e.target.value) - }} - /> - - - -
- -
- {candidates.length === 0 ? ( -

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

- ) : ( -
    - {candItems.map(({ asset, parsed }) => { - const label = formatSeasonEpisode(parsed) - return ( -
  • - -
  • - ) - })} -
- )} -
- {ready?.truncated && ( -

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

- )} - -
- )} +
+

+ {t('admin.shows.episodes')} +

+ {/* Полнометражке серию добавляют только пока её нет — вторая ей не положена. */} + {canAdd && ( + + )} +
@@ -358,6 +225,14 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
+ + {addOpen && ( + setAddOpen(false)} + onAdded={() => void invalidate()} + /> + )}
) } diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index f91be59..c3d8a7b 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -325,6 +325,8 @@ export const en = { episodes: 'Episodes', episode: 'Episode', noEpisodes: 'No episodes yet', + addMedia: 'Add media', + addMediaHint: 'Ready files not yet in this show. The selected ones are added as episodes.', filterAssets: 'Filter by name, e.g. Family.Guy.S16', selectAll: 'Select all', deselectAll: 'Clear', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 97d7761..b5fc1e0 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -326,6 +326,8 @@ export const ru = { episodes: 'Серии', episode: 'Серия', noEpisodes: 'Серий пока нет', + addMedia: 'Добавить медиа', + addMediaHint: 'Готовые файлы, ещё не добавленные в это шоу. Выбранные добавятся сериями.', filterAssets: 'Фильтр по имени, напр. Family.Guy.S16', selectAll: 'Выбрать все', deselectAll: 'Снять все',