Refactor GroupCatalog and enhance media preview functionality in ShowDetail and AddEpisodesDialog
ci / build-backend (push) Successful in 1m17s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 3m41s
ci / sonar (push) Successful in 3m33s

Updated the GroupCatalog class to streamline the determination of the dominant show kind by introducing a new method, DominantKindOf, which improves code clarity. Enhanced the AddEpisodesDialog and ShowDetail components to integrate a media preview feature, allowing users to preview media assets directly within the dialog. This includes the addition of a preview state and corresponding UI elements for a better user experience. Updated localization strings to support the new preview functionality.
This commit is contained in:
Leonid Pershin
2026-07-30 23:30:34 +03:00
parent bee655ec1c
commit 01ba48e163
3 changed files with 90 additions and 22 deletions
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Play, X } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
@@ -15,9 +16,10 @@ import {
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Pager } from '@/shared/ui/pager'
import { toast } from '@/shared/ui/toast-store'
import { listAllMedia } from '@/features/admin/media/api'
import { listAllMedia, mediaPreviewUrl } from '@/features/admin/media/api'
import {
type ParsedEpisode,
compareParsed,
@@ -56,6 +58,9 @@ export function AddEpisodesDialog({
const [deselected, setDeselected] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState<{ current: number; total: number } | null>(null)
const [page, setPage] = useState(1)
// Предпросмотр встроен в окно, а не открывается поверх: выбирают по именам файлов, и смотреть
// приходится подряд несколько — со второй модалкой это были бы два клика на каждый.
const [preview, setPreview] = useState<MediaAssetDto | null>(null)
const { data: ready, isLoading } = useQuery({
queryKey: qk.media.ready,
@@ -162,8 +167,9 @@ export function AddEpisodesDialog({
{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">
<li key={asset.id} className="flex items-center gap-2 pr-2 hover:bg-muted">
{/* Кнопка вне label: внутри неё клик по «плею» заодно снимал бы галочку. */}
<label className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 px-4 py-2">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
@@ -172,12 +178,31 @@ export function AddEpisodesDialog({
{label ? <Badge>{label}</Badge> : <Badge variant="muted"></Badge>}
<span className="truncate">{asset.originalFileName}</span>
</label>
<Button
size="sm"
variant="ghost"
title={t('admin.media.preview')}
onClick={() => setPreview(asset)}
>
<Play className="h-4 w-4" />
</Button>
</li>
)
})}
</ul>
)}
</div>
{preview && (
<div className="crt-panel flex flex-col gap-2 rounded-md p-3">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm">{preview.originalFileName}</span>
<Button size="sm" variant="ghost" onClick={() => setPreview(null)}>
<X className="h-4 w-4" />
</Button>
</div>
<HlsVideo src={mediaPreviewUrl(preview.id)} autoPlay />
</div>
)}
{ready?.truncated && (
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
)}
@@ -2,16 +2,19 @@ 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, Plus } from 'lucide-react'
import { ChevronLeft, Play, Plus } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
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 { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { formatSeasonEpisode, parseEpisodeName } from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/format'
import { mediaPreviewUrl } from '@/features/admin/media/api'
import { AddEpisodesDialog } from './AddEpisodesDialog'
import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard'
@@ -25,6 +28,8 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
const queryClient = useQueryClient()
const [addOpen, setAddOpen] = useState(false)
const [epPage, setEpPage] = useState(1)
/** Серия, которую смотрят: та же нарезка, что уйдёт в эфир. */
const [preview, setPreview] = useState<{ assetId: string; title: string } | null>(null)
const { data: show, isLoading } = useQuery({
queryKey: qk.shows.detail(showId),
@@ -234,13 +239,31 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
<div className="flex items-center gap-2">
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста нет. */}
{episode.assetStatus === 'Ready' && (
<Button
size="sm"
variant="outline"
title={t('admin.media.preview')}
onClick={() =>
setPreview({
assetId: episode.mediaAssetId,
title: episode.title ?? episode.assetName ?? t('admin.shows.episode'),
})
}
>
<Play className="h-4 w-4" />
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</div>
</td>
</tr>
)
@@ -257,6 +280,15 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
</div>
<Pager page={epPageSafe} totalPages={epTotalPages} onChange={setEpPage} />
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate">{preview?.title}</DialogTitle>
</DialogHeader>
{preview && <HlsVideo src={mediaPreviewUrl(preview.assetId)} autoPlay />}
</DialogContent>
</Dialog>
{addOpen && (
<AddEpisodesDialog
show={show}