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
+145
View File
@@ -0,0 +1,145 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio } from 'lucide-react'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryFn: listChannels,
})
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [selected])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming } = useMemo(() => splitEpg(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
<Radio className="h-4 w-4" />
{channel.name}
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{selected && watchReady ? (
<ChannelPlayer slug={selected} />
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
<div className="flex flex-col gap-3">
{current && (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{programLabel(current, t)}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((entry) => (
<li key={entry.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="w-12 shrink-0 text-muted-foreground">
{formatTime(entry.startsAtUtc)}
</span>
<span>{programLabel(entry, t)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function splitEpg(entries: ScheduleEntryDto[]) {
const now = Date.now()
const current = entries.find(
(e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now,
)
const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now)
return { current, upcoming }
}
function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) {
if (entry.kind === 'Ad') return t('air.ad')
const name = entry.showName ?? '—'
return entry.episodeIndex != null ? `${name} · ${t('air.episode')} ${entry.episodeIndex + 1}` : name
}