Add media and channel management routes: introduce new routes for admin channels, media, and shows in the routing structure. Update navigation in the admin layout to include links for these new sections. Enhance type definitions for media assets and shows in the API types. Integrate HLS.js for improved streaming support.

This commit is contained in:
Leonid Pershin
2026-07-24 16:18:54 +03:00
parent 1bbfd15907
commit a2685fb602
24 changed files with 2050 additions and 42 deletions
@@ -0,0 +1,157 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia, uploadMedia } from './api'
const PAGE_SIZE = 20
export function formatDuration(seconds: number | null): string {
if (seconds == null) return '—'
const total = Math.round(seconds)
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
const pad = (n: number) => String(n).padStart(2, '0')
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default',
Processing: 'muted',
Pending: 'muted',
Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [progress, setProgress] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media'],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
? 4000
: false,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
const handleFile = async (file: File) => {
setProgress(0)
try {
await uploadMedia(file, setProgress)
toast.success(t('admin.media.uploaded'))
invalidate()
} catch (error) {
onError(error)
} finally {
setProgress(null)
if (fileInput.current) fileInput.current.value = ''
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<div className="flex items-center gap-3">
{progress != null && (
<span className="text-sm text-muted-foreground">{progress}%</span>
)}
<input
ref={fileInput}
type="file"
accept="video/*,.mkv,.avi,.ts"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) void handleFile(file)
}}
/>
<Button
size="sm"
disabled={progress != null}
onClick={() => fileInput.current?.click()}
>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
</div>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.media.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.resolution')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('common.loading')}
</td>
</tr>
)}
{data?.items.map((asset) => (
<MediaRow
key={asset.id}
asset={asset}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
{data && data.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('admin.media.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; onDelete: () => void }) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2">{asset.originalFileName}</td>
<td className="px-4 py-2">
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{t(`admin.media.statuses.${asset.status}`)}
</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2">
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</td>
</tr>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
MediaAssetDto,
MediaAssetStatus,
PagedList,
} from '@/shared/api/types'
export type ListMediaParams = {
page: number
pageSize: number
status?: MediaAssetStatus
search?: string
}
export function listMedia(params: ListMediaParams) {
const query = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
})
if (params.status) query.set('status', params.status)
if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
export function deleteMedia(id: string) {
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
}
/**
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
*/
export function uploadMedia(
file: File,
onProgress?: (percent: number) => void,
): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open('POST', `/api/admin/media?${query.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress)
onProgress(Math.round((event.loaded / event.total) * 100))
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}