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.
203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
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>
|
|
)
|
|
}
|