From be6947af06ff94b2b94c6c6fffa069747bf05b40 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 03:27:52 +0300 Subject: [PATCH] Refactor ShowDetail component and add media management features Updated the ShowDetail component to streamline episode management by removing unused state and logic related to candidate episodes. Introduced an AddEpisodesDialog for adding media assets to shows. Enhanced localization by adding new strings for media management in both English and Russian. This refactor improves code clarity and user experience when managing show episodes. --- .../admin/shows/AddEpisodesDialog.tsx | 202 ++++++++++++++++++ .../src/features/admin/shows/ShowDetail.tsx | 183 +++------------- frontend/src/shared/lib/locales/en.ts | 2 + frontend/src/shared/lib/locales/ru.ts | 2 + 4 files changed, 235 insertions(+), 154 deletions(-) create mode 100644 frontend/src/features/admin/shows/AddEpisodesDialog.tsx 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: 'Снять все',