Refactor ShowDetail component and add media management features
ci / build-backend (push) Successful in 1m35s
ci / build-frontend (push) Successful in 40s
ci / tests (push) Successful in 2m10s
ci / sonar (push) Failing after 32s

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.
This commit is contained in:
Leonid Pershin
2026-07-27 03:27:52 +03:00
parent 790d01b587
commit be6947af06
4 changed files with 235 additions and 154 deletions
@@ -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<typeof useTranslation>['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<Set<string>>(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<Candidate[]>(() => {
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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t('admin.shows.addMedia')}</DialogTitle>
<DialogDescription>{t('admin.shows.addMediaHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-md"
placeholder={t('admin.shows.filterAssets')}
value={filter}
onChange={(e) => {
setPage(1)
setFilter(e.target.value)
}}
/>
<Button size="sm" variant="outline" onClick={() => setDeselected(new Set())}>
{t('admin.shows.selectAll')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDeselected(new Set(candidates.map((c) => c.asset.id)))}
>
{t('admin.shows.deselectAll')}
</Button>
</div>
<div className="crt-panel max-h-96 overflow-y-auto rounded-md">
{isLoading && (
<p className="px-4 py-3 text-sm text-muted-foreground">{t('common.loading')}</p>
)}
{!isLoading && candidates.length === 0 && (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('admin.shows.noMatches')}
</p>
)}
{candidates.length > 0 && (
<ul className="divide-y divide-border text-sm">
{items.map(({ asset, parsed }) => {
const label = formatSeasonEpisode(parsed)
return (
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
)
})}
</ul>
)}
</div>
{ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)}
<Pager page={pageSafe} totalPages={totalPages} onChange={setPage} />
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
disabled={selected.length === 0 || adding != null}
onClick={() => void bulkAdd()}
>
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+29 -154
View File
@@ -2,66 +2,34 @@ import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react' import { ChevronLeft, Plus } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import { import { AUDIENCE_UNSET, SHOW_AUDIENCES, type ShowAudience } from '@/shared/api/types'
AUDIENCE_UNSET,
SHOW_AUDIENCES,
type MediaAssetDto,
type ShowAudience,
} from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge' import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Pager } from '@/shared/ui/pager' import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store' import { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse'
import { listAllMedia } from '@/features/admin/media/api'
import {
type ParsedEpisode,
compareParsed,
formatSeasonEpisode,
parseEpisodeName,
} from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/format' import { formatDuration } from '@/features/admin/media/format'
import { AddEpisodesDialog } from './AddEpisodesDialog'
import { ShowGenresField } from './ShowGenresField' import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard' import { ShowMetadataCard } from './ShowMetadataCard'
import { imageUrl } from '@/features/admin/images/api' import { imageUrl } from '@/features/admin/images/api'
import { addEpisode, getShow, removeEpisode, setShowAudience } from './api' import { getShow, removeEpisode, setShowAudience } from './api'
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
const PAGE_SIZE = 20 const PAGE_SIZE = 20
/** Пока идёт пакетное добавление — прогресс вместо подписи; серии добавляются по одной. */
function addButtonLabel(
progress: { current: number; total: number } | null,
count: number,
t: ReturnType<typeof useTranslation>['t'],
) {
if (progress) return `${progress.current}/${progress.total}`
return `${t('admin.shows.addSelected')} (${count})`
}
export function ShowDetail({ showId }: Readonly<{ showId: string }>) { export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [filter, setFilter] = useState('') const [addOpen, setAddOpen] = useState(false)
const [deselected, setDeselected] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState<{ current: number; total: number } | null>(null)
const [candPage, setCandPage] = useState(1)
const [epPage, setEpPage] = useState(1) const [epPage, setEpPage] = useState(1)
const { data: show, isLoading } = useQuery({ const { data: show, isLoading } = useQuery({
queryKey: qk.shows.detail(showId), queryKey: qk.shows.detail(showId),
queryFn: () => getShow(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 invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = useApiError() const onError = useApiError()
@@ -77,26 +45,6 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
onError, onError,
}) })
// Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные
// по распознанному номеру сезона/серии (нераспознанные — в конец по имени).
const candidates = useMemo<Candidate[]>(() => {
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 в именах серий (отсортированы по возрастанию). // Какие сезоны загружены — по распознанным SxxExx в именах серий (отсортированы по возрастанию).
const seasons = useMemo(() => { const seasons = useMemo(() => {
if (!show) return [] as number[] if (!show) return [] as number[]
@@ -110,44 +58,14 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p> if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
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 epTotalPages = Math.max(1, Math.ceil(show.episodes.length / PAGE_SIZE))
const epPageSafe = Math.min(epPage, epTotalPages) const epPageSafe = Math.min(epPage, epTotalPages)
const epOffset = (epPageSafe - 1) * PAGE_SIZE const epOffset = (epPageSafe - 1) * PAGE_SIZE
const epItems = show.episodes.slice(epOffset, epOffset + 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -220,69 +138,18 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
<ShowMetadataCard show={show} onChanged={invalidate} /> <ShowMetadataCard show={show} onChanged={invalidate} />
{canAdd && ( <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-col gap-2"> <h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<div className="flex flex-wrap items-center gap-2"> {t('admin.shows.episodes')}
<Input </h3>
className="max-w-md" {/* Полнометражке серию добавляют только пока её нет — вторая ей не положена. */}
placeholder={t('admin.shows.filterAssets')} {canAdd && (
value={filter} <Button size="sm" onClick={() => setAddOpen(true)}>
onChange={(e) => { <Plus className="h-4 w-4" />
setCandPage(1) {t('admin.shows.addMedia')}
setFilter(e.target.value) </Button>
}} )}
/> </div>
<Button size="sm" variant="outline" onClick={() => setDeselected(new Set())}>
{t('admin.shows.selectAll')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDeselected(new Set(candidates.map((c) => c.asset.id)))}
>
{t('admin.shows.deselectAll')}
</Button>
<Button
size="sm"
disabled={selected.length === 0 || adding != null}
onClick={() => void bulkAdd()}
>
{addButtonLabel(adding, isSingle ? Math.min(1, selected.length) : selected.length, t)}
</Button>
</div>
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
{candidates.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('admin.shows.noMatches')}
</p>
) : (
<ul className="divide-y divide-border text-sm">
{candItems.map(({ asset, parsed }) => {
const label = formatSeasonEpisode(parsed)
return (
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
)
})}
</ul>
)}
</div>
{ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)}
<Pager page={candPageSafe} totalPages={candTotalPages} onChange={setCandPage} />
</div>
)}
<div className="crt-panel overflow-x-auto rounded-md"> <div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -358,6 +225,14 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
</table> </table>
</div> </div>
<Pager page={epPageSafe} totalPages={epTotalPages} onChange={setEpPage} /> <Pager page={epPageSafe} totalPages={epTotalPages} onChange={setEpPage} />
{addOpen && (
<AddEpisodesDialog
show={show}
onClose={() => setAddOpen(false)}
onAdded={() => void invalidate()}
/>
)}
</div> </div>
) )
} }
+2
View File
@@ -325,6 +325,8 @@ export const en = {
episodes: 'Episodes', episodes: 'Episodes',
episode: 'Episode', episode: 'Episode',
noEpisodes: 'No episodes yet', 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', filterAssets: 'Filter by name, e.g. Family.Guy.S16',
selectAll: 'Select all', selectAll: 'Select all',
deselectAll: 'Clear', deselectAll: 'Clear',
+2
View File
@@ -326,6 +326,8 @@ export const ru = {
episodes: 'Серии', episodes: 'Серии',
episode: 'Серия', episode: 'Серия',
noEpisodes: 'Серий пока нет', noEpisodes: 'Серий пока нет',
addMedia: 'Добавить медиа',
addMediaHint: 'Готовые файлы, ещё не добавленные в это шоу. Выбранные добавятся сериями.',
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16', filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
selectAll: 'Выбрать все', selectAll: 'Выбрать все',
deselectAll: 'Снять все', deselectAll: 'Снять все',