Refactor GroupCatalog and enhance media preview functionality in ShowDetail and AddEpisodesDialog
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user