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:
@@ -0,0 +1,660 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
ChannelShowDto,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
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 { listShows } from '@/features/admin/shows/api'
|
||||
import {
|
||||
addChannelAd,
|
||||
addChannelShow,
|
||||
createOverride,
|
||||
deleteOverride,
|
||||
getChannel,
|
||||
getSchedule,
|
||||
regenerateSchedule,
|
||||
removeChannelAd,
|
||||
removeChannelShow,
|
||||
updateChannelSettings,
|
||||
updateChannelShow,
|
||||
} from './api'
|
||||
|
||||
function formatTime(iso: string) {
|
||||
return new Date(iso).toLocaleString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready'],
|
||||
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
|
||||
})
|
||||
const { data: schedule } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'schedule'],
|
||||
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
|
||||
}
|
||||
const invalidateSchedule = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => regenerateSchedule(channelId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.channels.regenerated'))
|
||||
void invalidateSchedule()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/channels">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
<Badge variant="muted">{channel.slug}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={regenerateMutation.isPending}
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('admin.channels.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{/* Шоу канала */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.shows')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<AddShowForm
|
||||
channelId={channelId}
|
||||
options={availableShows.map((s) => ({ id: s.id, name: s.name }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">{t('admin.channels.show')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.weight')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.block')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.on')}</th>
|
||||
<th className="py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channel.shows.map((row) => (
|
||||
<ChannelShowRow
|
||||
key={row.id}
|
||||
channelId={channelId}
|
||||
row={row}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{channel.shows.length === 0 && (
|
||||
<tr>
|
||||
<td className="py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('admin.channels.noShows')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Реклама */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.ads')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<AddAdForm
|
||||
channelId={channelId}
|
||||
options={(ready?.items ?? [])
|
||||
.filter((a) => !channel.ads.some((ad) => ad.mediaAssetId === a.id))
|
||||
.map((a) => ({ id: a.id, name: a.originalFileName }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.ads.map((ad) => (
|
||||
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>{ad.assetName ?? '—'}</span>
|
||||
<RemoveButton
|
||||
onClick={() =>
|
||||
removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.ads.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noAds')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.overrides')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<OverrideForm
|
||||
channelId={channelId}
|
||||
options={channel.shows.map((cs) => ({ id: cs.showId, name: cs.showName }))}
|
||||
onCreated={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.overrides.map((o) => (
|
||||
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>
|
||||
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
|
||||
{formatTime(o.startsAtUtc)} – {formatTime(o.endsAtUtc)} ·{' '}
|
||||
{o.shows.map((s) => s.showName).join(', ')}
|
||||
</span>
|
||||
<RemoveButton
|
||||
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.overrides.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noOverrides')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Предпросмотр расписания */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.schedule')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: import('@/shared/api/types').ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setAdInsertion(channel.adInsertion)
|
||||
setAdsPerBreak(channel.adsPerBreak)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('admin.channels.settings')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adPolicy')}</Label>
|
||||
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
|
||||
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adsPerBreak')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={adsPerBreak}
|
||||
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select value={fillerAssetId || 'none'} onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||
/>
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AddShowForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
|
||||
const [blockValue, setBlockValue] = useState(1)
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField
|
||||
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
|
||||
value={blockValue}
|
||||
onChange={setBlockValue}
|
||||
min={1}
|
||||
/>
|
||||
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChannelShowRow({
|
||||
channelId,
|
||||
row,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
row: ChannelShowDto
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [weight, setWeight] = useState(row.weight)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={(e) => setIsEnabled(e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function AddAdForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [assetId, setAssetId] = useState('')
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelAd(channelId, assetId),
|
||||
onSuccess: () => {
|
||||
setAssetId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger className="max-w-md">
|
||||
<SelectValue placeholder={t('admin.channels.pickAd')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverrideForm({
|
||||
channelId,
|
||||
options,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onCreated: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createOverride(channelId, {
|
||||
mode,
|
||||
startsAtUtc: new Date(start).toISOString(),
|
||||
endsAtUtc: new Date(end).toISOString(),
|
||||
shows: [{ showId, weight }],
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
onCreated()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const valid = showId && start && end && new Date(end) > new Date(start)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
|
||||
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'Boost' && (
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-52" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-52" />
|
||||
</div>
|
||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
const { t } = useTranslation()
|
||||
if (entries.length === 0)
|
||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : (
|
||||
<span>
|
||||
{e.showName ?? '—'}
|
||||
{e.episodeIndex != null && (
|
||||
<span className="text-muted-foreground"> · {t('air.episode')} {e.episodeIndex + 1}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RemoveButton({ onClick }: { onClick: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Button size="sm" variant="destructive" onClick={onClick}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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 { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createChannel, listChannels } from './api'
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
export function ChannelsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [slug, setSlug] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
setSlug('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.channels.title')}</h2>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.channels.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.channels.slug')}
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(slugify(e.target.value))}
|
||||
/>
|
||||
<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.channels.name')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.channels.slug')}</th>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.channels.state')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={3}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{data?.map((channel) => (
|
||||
<tr key={channel.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to="/admin/channels/$channelId"
|
||||
params={{ channelId: channel.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{channel.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{channel.slug}</td>
|
||||
<td className="px-4 py-2">
|
||||
{channel.isEnabled ? (
|
||||
<Badge>{t('admin.channels.enabled')}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted">{t('admin.channels.disabled')}</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
OverrideMode,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export type ChannelShowBody = {
|
||||
showId: string
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
}
|
||||
|
||||
export function addChannelShow(id: string, body: ChannelShowBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/shows`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateChannelShow(
|
||||
id: string,
|
||||
channelShowId: string,
|
||||
body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function removeChannelShow(id: string, channelShowId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addChannelAd(id: string, mediaAssetId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/ads`, {
|
||||
method: 'POST',
|
||||
body: { mediaAssetId },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeChannelAd(id: string, channelAdId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
mode: OverrideMode
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
shows: { showId: string; weight: number }[]
|
||||
}
|
||||
|
||||
export function createOverride(id: string, body: OverrideBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/overrides`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function deleteOverride(id: string, overrideId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function regenerateSchedule(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/regenerate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import Hls from 'hls.js'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* HLS-плеер канала. Cookie tw_stream (см. watchChannel) уже выдана к моменту монтирования, поэтому
|
||||
* запросы плейлиста/сегментов авторизуются автоматически. hls.js для всех браузеров, нативный HLS —
|
||||
* фолбэк для Safari/iOS.
|
||||
*/
|
||||
export function ChannelPlayer({ slug }: { slug: string }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const { t } = useTranslation()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
setError(null)
|
||||
const src = `/api/channels/${slug}/live.m3u8`
|
||||
let hls: Hls | null = null
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ liveSyncDurationCount: 3, enableWorker: true, lowLatencyMode: false })
|
||||
hls.loadSource(src)
|
||||
hls.attachMedia(video)
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => void video.play().catch(() => undefined))
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) setError(t('air.playbackError'))
|
||||
})
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = src
|
||||
video.addEventListener('loadedmetadata', () => void video.play().catch(() => undefined))
|
||||
video.addEventListener('error', () => setError(t('air.playbackError')))
|
||||
} else {
|
||||
setError(t('air.unsupported'))
|
||||
}
|
||||
|
||||
return () => {
|
||||
hls?.destroy()
|
||||
}
|
||||
}, [slug, t])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<video
|
||||
ref={videoRef}
|
||||
controls
|
||||
playsInline
|
||||
muted
|
||||
className="aspect-video w-full rounded-md border border-border bg-black"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { PublicChannelDto, ScheduleEntryDto } from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<PublicChannelDto[]>('/channels')
|
||||
}
|
||||
|
||||
/** Выдаёт httpOnly-cookie tw_stream — после этого <video> сможет грузить плейлист и сегменты. */
|
||||
export function watchChannel(slug: string) {
|
||||
return apiRequest<void>(`/channels/${slug}/watch`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getEpg(slug: string, from?: Date, to?: Date) {
|
||||
const query = new URLSearchParams()
|
||||
if (from) query.set('from', from.toISOString())
|
||||
if (to) query.set('to', to.toISOString())
|
||||
const qs = query.toString()
|
||||
return apiRequest<ScheduleEntryDto[]>(`/channels/${slug}/epg${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
Reference in New Issue
Block a user