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,136 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { addEpisode, getShow, removeEpisode } from './api'
export function ShowDetail({ showId }: { showId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [assetId, setAssetId] = useState('')
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const addMutation = useMutation({
mutationFn: () => addEpisode(showId, assetId),
onSuccess: () => {
setAssetId('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate,
onError,
})
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
const canAdd = show.kind !== 'Single' || show.episodes.length === 0
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="ghost">
<Link to="/admin/shows">
<ChevronLeft className="h-4 w-4" />
{t('admin.shows.title')}
</Link>
</Button>
</div>
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</div>
{canAdd && (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.shows.pickAsset')} />
</SelectTrigger>
<SelectContent>
{ready?.items.map((asset) => (
<SelectItem key={asset.id} value={asset.id}>
{asset.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || addMutation.isPending} onClick={() => addMutation.mutate()}>
{t('admin.shows.addEpisode')}
</Button>
</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">#</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episode')}</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.status')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{show.episodes.map((episode, index) => (
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">{episode.assetName ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">
{formatDuration(episode.durationSeconds)}
</td>
<td className="px-4 py-2">
{episode.assetStatus && (
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
{t(`admin.media.statuses.${episode.assetStatus}`)}
</Badge>
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
{show.episodes.length === 0 && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('admin.shows.noEpisodes')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { createShow, deleteShow, listShows } from './api'
export function ShowsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createShow({ name: name.trim(), kind }),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.shows.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</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.shows.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</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={4}>
{t('common.loading')}
</td>
</tr>
)}
{data?.map((show) => (
<tr key={show.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<Link
to="/admin/shows/$showId"
params={{ showId: show.id }}
className="text-primary hover:underline"
>
{show.name}
</Link>
</td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(show.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, ShowDto, ShowKind, ShowSummaryDto } from '@/shared/api/types'
export function listShows() {
return apiRequest<ShowSummaryDto[]>('/admin/shows')
}
export function getShow(id: string) {
return apiRequest<ShowDto>(`/admin/shows/${id}`)
}
export function createShow(body: { name: string; kind: ShowKind; description?: string }) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
}
export function deleteShow(id: string) {
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
}
export function addEpisode(showId: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/shows/${showId}/episodes`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeEpisode(showId: string, episodeId: string) {
return apiRequest<void>(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' })
}