Refactor media preview functionality and update localization strings
ci / build-backend (push) Successful in 1m23s
ci / build-frontend (push) Successful in 39s
ci / tests (push) Successful in 1m22s
ci / sonar (push) Successful in 3m43s

Moved the mediaPreviewUrl function to the media API for better organization and clarity. Updated the MediaPanel component to include a preview feature for media assets, allowing users to view processed clips. Enhanced the UI to include a dialog for media previews and added corresponding localization strings in both English and Russian for the new preview functionality.
This commit is contained in:
Leonid Pershin
2026-07-30 23:24:35 +03:00
parent b9d96c915c
commit bee655ec1c
6 changed files with 50 additions and 12 deletions
@@ -16,7 +16,8 @@ import { HlsVideo } from '@/shared/ui/hls-video'
import { Input } from '@/shared/ui/input' import { Input } from '@/shared/ui/input'
import { BlockBuilder } from './BlockBuilder' import { BlockBuilder } from './BlockBuilder'
import { ClipGroupPanel } from './ClipGroupPanel' import { ClipGroupPanel } from './ClipGroupPanel'
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api' import { mediaPreviewUrl } from '@/features/admin/media/api'
import { listInterstitialBlocks, listInterstitials } from './api'
import { setDragItem } from './dnd' import { setDragItem } from './dnd'
import { formatClock } from './format' import { formatClock } from './format'
@@ -16,8 +16,3 @@ export function importInterstitials(mediaAssetIds: string[]) {
body: { mediaAssetIds }, body: { mediaAssetIds },
}) })
} }
/** Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js. */
export function mediaPreviewUrl(assetId: string) {
return `/api/admin/media/${assetId}/preview/index.m3u8`
}
@@ -1,18 +1,20 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FolderInput, Upload } from 'lucide-react' import { FolderInput, Play, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys' import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types' import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error' import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge' import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button' 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 { 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 { toast } from '@/shared/ui/toast-store'
import { useTableSort } from '@/shared/lib/table-sort' import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable' import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api' import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl } from './api'
import { formatDuration, splitEta } from './format' import { formatDuration, splitEta } from './format'
import { MediaImportDialog } from './MediaImportDialog' import { MediaImportDialog } from './MediaImportDialog'
import { useUploadStore } from './upload-store' import { useUploadStore } from './upload-store'
@@ -45,6 +47,7 @@ export function MediaPanel() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('created', true) const { sort, toggle } = useTableSort('created', true)
const [importOpen, setImportOpen] = useState(false) const [importOpen, setImportOpen] = useState(false)
const [preview, setPreview] = useState<MediaAssetDto | null>(null)
const enqueue = useUploadStore((s) => s.enqueue) const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => { const sortColumn = (key: string) => {
@@ -230,6 +233,7 @@ export function MediaPanel() {
<MediaRow <MediaRow
key={asset.id} key={asset.id}
asset={asset} asset={asset}
onPreview={() => setPreview(asset)}
onDelete={() => deleteMutation.mutate(asset.id)} onDelete={() => deleteMutation.mutate(asset.id)}
/> />
))} ))}
@@ -249,6 +253,17 @@ export function MediaPanel() {
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1} totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
onChange={setPage} onChange={setPage}
/> />
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate">{preview?.originalFileName}</DialogTitle>
</DialogHeader>
{/* Смотрят нарезку, а не исходник: в эфир идёт именно она, и битый звук или чёрный кадр
видно только здесь. Кнопка в списке — это «плей», поэтому запускаем сразу. */}
{preview && <HlsVideo src={mediaPreviewUrl(preview.id)} autoPlay />}
</DialogContent>
</Dialog>
</div> </div>
) )
} }
@@ -268,7 +283,11 @@ function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
return <>~{[hours, minutes].filter(Boolean).join(' ')}</> return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
} }
function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete: () => void }>) { function MediaRow({
asset,
onPreview,
onDelete,
}: Readonly<{ asset: MediaAssetDto; onPreview: () => void; onDelete: () => void }>) {
const { t } = useTranslation() const { t } = useTranslation()
return ( return (
<tr className="border-b border-border last:border-0"> <tr className="border-b border-border last:border-0">
@@ -286,9 +305,22 @@ function MediaRow({ asset, onDelete }: Readonly<{ asset: MediaAssetDto; onDelete
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'} {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<div className="flex items-center gap-2">
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста ещё нет. */}
{asset.status === 'Ready' && (
<Button
size="sm"
variant="outline"
title={t('admin.media.preview')}
onClick={onPreview}
>
<Play className="h-4 w-4" />
</Button>
)}
<Button size="sm" variant="destructive" onClick={onDelete}> <Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')} {t('common.delete')}
</Button> </Button>
</div>
</td> </td>
</tr> </tr>
) )
+8
View File
@@ -161,3 +161,11 @@ export function importMovies(items: MovieImportItem[], provider: string) {
body: { items, provider }, body: { items, provider },
}) })
} }
/**
* Плейлист обработанного ассета под admin-роутом (JWT) — грузится через hls.js, а не ставится
* в <video> напрямую: сегменты лежат за той же авторизацией, что и плейлист.
*/
export function mediaPreviewUrl(assetId: string) {
return `/api/admin/media/${assetId}/preview/index.m3u8`
}
+1
View File
@@ -297,6 +297,7 @@ export const en = {
media: { media: {
title: 'Media', title: 'Media',
upload: 'Upload', upload: 'Upload',
preview: 'Preview',
manualButton: 'From manual folder', manualButton: 'From manual folder',
importButton: 'Import', importButton: 'Import',
importTitle: 'Media import', importTitle: 'Media import',
+1
View File
@@ -298,6 +298,7 @@ export const ru = {
media: { media: {
title: 'Медиа', title: 'Медиа',
upload: 'Загрузить', upload: 'Загрузить',
preview: 'Просмотр',
manualButton: 'Из папки manual', manualButton: 'Из папки manual',
importButton: 'Импорт', importButton: 'Импорт',
importTitle: 'Импорт медиа', importTitle: 'Импорт медиа',