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}` : ''}`)
|
||||
}
|
||||
@@ -16,8 +16,13 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as RegisterRouteImport } from './routes/register'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as AdminIndexRouteImport } from './routes/admin/index'
|
||||
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
|
||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
|
||||
import { Route as AdminShowsShowIdRouteImport } from './routes/admin/shows.$showId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
@@ -54,16 +59,41 @@ const AdminIndexRoute = AdminIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminChannelsRoute = AdminChannelsRouteImport.update({
|
||||
id: '/channels',
|
||||
path: '/channels',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminMediaRoute = AdminMediaRouteImport.update({
|
||||
id: '/media',
|
||||
path: '/media',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminRolesRoute = AdminRolesRouteImport.update({
|
||||
id: '/roles',
|
||||
path: '/roles',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminShowsRoute = AdminShowsRouteImport.update({
|
||||
id: '/shows',
|
||||
path: '/shows',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminUsersRoute = AdminUsersRouteImport.update({
|
||||
id: '/users',
|
||||
path: '/users',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminChannelsChannelIdRoute = AdminChannelsChannelIdRouteImport.update({
|
||||
id: '/$channelId',
|
||||
path: '/$channelId',
|
||||
getParentRoute: () => AdminChannelsRoute,
|
||||
} as any)
|
||||
const AdminShowsShowIdRoute = AdminShowsShowIdRouteImport.update({
|
||||
id: '/$showId',
|
||||
path: '/$showId',
|
||||
getParentRoute: () => AdminShowsRoute,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
@@ -72,9 +102,14 @@ export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
@@ -82,9 +117,14 @@ export interface FileRoutesByTo {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
@@ -94,9 +134,14 @@ export interface FileRoutesById {
|
||||
'/login': typeof LoginRoute
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
'/admin/shows': typeof AdminShowsRouteWithChildren
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
@@ -107,9 +152,14 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/shows/$showId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
@@ -117,9 +167,14 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/shows/$showId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
@@ -128,9 +183,14 @@ export interface FileRouteTypes {
|
||||
| '/login'
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
| '/admin/shows'
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/shows/$showId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
@@ -193,6 +253,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminIndexRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/channels': {
|
||||
id: '/admin/channels'
|
||||
path: '/channels'
|
||||
fullPath: '/admin/channels'
|
||||
preLoaderRoute: typeof AdminChannelsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/media': {
|
||||
id: '/admin/media'
|
||||
path: '/media'
|
||||
fullPath: '/admin/media'
|
||||
preLoaderRoute: typeof AdminMediaRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/roles': {
|
||||
id: '/admin/roles'
|
||||
path: '/roles'
|
||||
@@ -200,6 +274,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminRolesRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/shows': {
|
||||
id: '/admin/shows'
|
||||
path: '/shows'
|
||||
fullPath: '/admin/shows'
|
||||
preLoaderRoute: typeof AdminShowsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/users': {
|
||||
id: '/admin/users'
|
||||
path: '/users'
|
||||
@@ -207,17 +288,61 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminUsersRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/channels/$channelId': {
|
||||
id: '/admin/channels/$channelId'
|
||||
path: '/$channelId'
|
||||
fullPath: '/admin/channels/$channelId'
|
||||
preLoaderRoute: typeof AdminChannelsChannelIdRouteImport
|
||||
parentRoute: typeof AdminChannelsRoute
|
||||
}
|
||||
'/admin/shows/$showId': {
|
||||
id: '/admin/shows/$showId'
|
||||
path: '/$showId'
|
||||
fullPath: '/admin/shows/$showId'
|
||||
preLoaderRoute: typeof AdminShowsShowIdRouteImport
|
||||
parentRoute: typeof AdminShowsRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AdminChannelsRouteChildren {
|
||||
AdminChannelsChannelIdRoute: typeof AdminChannelsChannelIdRoute
|
||||
}
|
||||
|
||||
const AdminChannelsRouteChildren: AdminChannelsRouteChildren = {
|
||||
AdminChannelsChannelIdRoute: AdminChannelsChannelIdRoute,
|
||||
}
|
||||
|
||||
const AdminChannelsRouteWithChildren = AdminChannelsRoute._addFileChildren(
|
||||
AdminChannelsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminShowsRouteChildren {
|
||||
AdminShowsShowIdRoute: typeof AdminShowsShowIdRoute
|
||||
}
|
||||
|
||||
const AdminShowsRouteChildren: AdminShowsRouteChildren = {
|
||||
AdminShowsShowIdRoute: AdminShowsShowIdRoute,
|
||||
}
|
||||
|
||||
const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
|
||||
AdminShowsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
AdminShowsRoute: typeof AdminShowsRouteWithChildren
|
||||
AdminUsersRoute: typeof AdminUsersRoute
|
||||
AdminIndexRoute: typeof AdminIndexRoute
|
||||
}
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
AdminShowsRoute: AdminShowsRouteWithChildren,
|
||||
AdminUsersRoute: AdminUsersRoute,
|
||||
AdminIndexRoute: AdminIndexRoute,
|
||||
}
|
||||
|
||||
@@ -14,7 +14,28 @@ function AdminLayout() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('nav.admin')}</h1>
|
||||
<nav className="flex gap-4 border-b border-border text-sm">
|
||||
<nav className="flex flex-wrap gap-4 border-b border-border text-sm">
|
||||
<Link
|
||||
to="/admin/media"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.media.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/shows"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.shows.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/channels"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/roles"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ChannelDetail } from '@/features/admin/channels/ChannelDetail'
|
||||
|
||||
export const Route = createFileRoute('/admin/channels/$channelId')({ component: ChannelDetailRoute })
|
||||
|
||||
function ChannelDetailRoute() {
|
||||
const { channelId } = Route.useParams()
|
||||
return <ChannelDetail channelId={channelId} />
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ChannelsPanel } from '@/features/admin/channels/ChannelsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/channels')({ component: ChannelsPanel })
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createFileRoute, Navigate } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/admin/')({
|
||||
component: () => <Navigate to="/admin/users" />,
|
||||
component: () => <Navigate to="/admin/media" />,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { MediaPanel } from '@/features/admin/media/MediaPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/media')({ component: MediaPanel })
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ShowDetail } from '@/features/admin/shows/ShowDetail'
|
||||
|
||||
export const Route = createFileRoute('/admin/shows/$showId')({ component: ShowDetailRoute })
|
||||
|
||||
function ShowDetailRoute() {
|
||||
const { showId } = Route.useParams()
|
||||
return <ShowDetail showId={showId} />
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { ShowsPanel } from '@/features/admin/shows/ShowsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/shows')({ component: ShowsPanel })
|
||||
@@ -1,38 +1,13 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tv } from 'lucide-react'
|
||||
import { useRequireAuth } from '@/features/auth/guards'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
|
||||
import { AirPage } from '@/features/streaming/AirPage'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({ component: DashboardPage })
|
||||
|
||||
function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
const { user, isReady } = useRequireAuth()
|
||||
const { isReady } = useRequireAuth()
|
||||
|
||||
if (!isReady || !user) return null
|
||||
if (!isReady) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h1 className="crt-glow text-2xl font-bold">{t('dashboard.welcome', { userName: user.userName })}</h1>
|
||||
<Badge>
|
||||
{t('dashboard.role')}: {user.role}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Tv className="h-5 w-5" />
|
||||
{t('nav.dashboard')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">{t('dashboard.placeholder')}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
return <AirPage />
|
||||
}
|
||||
|
||||
@@ -36,3 +36,120 @@ export type PagedList<T> = {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
export type CreatedIdResponse = { id: string }
|
||||
|
||||
// ── Медиа ────────────────────────────────────────────────────────────────
|
||||
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
|
||||
export type MediaSource = 'Upload' | 'Inbox'
|
||||
|
||||
export type MediaAssetDto = {
|
||||
id: string
|
||||
originalFileName: string
|
||||
source: MediaSource
|
||||
status: MediaAssetStatus
|
||||
durationSeconds: number | null
|
||||
segmentCount: number | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
videoCodec: string | null
|
||||
audioCodec: string | null
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
|
||||
export type ShowSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
kind: ShowKind
|
||||
episodeCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
position: number
|
||||
assetName: string | null
|
||||
assetStatus: MediaAssetStatus | null
|
||||
durationSeconds: number | null
|
||||
}
|
||||
|
||||
export type ShowDto = {
|
||||
id: string
|
||||
name: string
|
||||
kind: ShowKind
|
||||
description: string | null
|
||||
episodes: EpisodeDto[]
|
||||
}
|
||||
|
||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||
export type BlockMode = 'Count' | 'Duration'
|
||||
export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
|
||||
export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad'
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export type ChannelShowDto = {
|
||||
id: string
|
||||
showId: string
|
||||
showName: string
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
nextEpisodeIndex: number
|
||||
}
|
||||
|
||||
export type ChannelAdDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
assetName: string | null
|
||||
position: number
|
||||
}
|
||||
|
||||
export type OverrideShowDto = { showId: string; showName: string; weight: number }
|
||||
|
||||
export type ProgrammingOverrideDto = {
|
||||
id: string
|
||||
mode: OverrideMode
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
shows: OverrideShowDto[]
|
||||
}
|
||||
|
||||
export type ChannelDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
fillerAssetId: string | null
|
||||
shows: ChannelShowDto[]
|
||||
ads: ChannelAdDto[]
|
||||
overrides: ProgrammingOverrideDto[]
|
||||
}
|
||||
|
||||
export type ScheduleEntryDto = {
|
||||
id: string
|
||||
kind: ScheduleEntryKind
|
||||
mediaAssetId: string
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showId: string | null
|
||||
showName: string | null
|
||||
episodeIndex: number | null
|
||||
}
|
||||
|
||||
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
||||
export type PublicChannelDto = { id: string; slug: string; name: string }
|
||||
|
||||
@@ -57,6 +57,15 @@ const resources = {
|
||||
placeholder: 'Список каналов появится здесь позже — пока в эфире только тестовая заставка.',
|
||||
role: 'Роль',
|
||||
},
|
||||
air: {
|
||||
now: 'Сейчас',
|
||||
next: 'Далее',
|
||||
ad: 'Реклама',
|
||||
episode: 'Серия',
|
||||
noChannels: 'Пока нет доступных каналов. Загляните позже.',
|
||||
playbackError: 'Не удалось воспроизвести поток',
|
||||
unsupported: 'Браузер не поддерживает воспроизведение HLS',
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки аккаунта',
|
||||
changeUserName: 'Смена имени пользователя',
|
||||
@@ -91,6 +100,72 @@ const resources = {
|
||||
unblock: 'Разблокировать',
|
||||
filterAll: 'Все роли',
|
||||
},
|
||||
media: {
|
||||
title: 'Медиа',
|
||||
upload: 'Загрузить',
|
||||
uploaded: 'Файл загружен, идёт обработка',
|
||||
name: 'Файл',
|
||||
status: 'Статус',
|
||||
duration: 'Длительность',
|
||||
resolution: 'Разрешение',
|
||||
empty: 'Пока нет загруженных файлов',
|
||||
statuses: {
|
||||
Pending: 'В очереди',
|
||||
Processing: 'Обработка',
|
||||
Ready: 'Готов',
|
||||
Failed: 'Ошибка',
|
||||
},
|
||||
},
|
||||
shows: {
|
||||
title: 'Шоу',
|
||||
name: 'Название',
|
||||
kind: 'Тип',
|
||||
kinds: { Series: 'Сериал', Single: 'Полнометражка' },
|
||||
episodes: 'Серии',
|
||||
episode: 'Серия',
|
||||
addEpisode: 'Добавить серию',
|
||||
pickAsset: 'Выберите файл',
|
||||
noEpisodes: 'Серий пока нет',
|
||||
},
|
||||
channels: {
|
||||
title: 'Каналы',
|
||||
name: 'Название',
|
||||
slug: 'Slug',
|
||||
state: 'Состояние',
|
||||
enabled: 'В эфире',
|
||||
disabled: 'Выключен',
|
||||
enabledLabel: 'Канал в эфире',
|
||||
regenerate: 'Пересобрать',
|
||||
regenerated: 'Расписание пересобрано',
|
||||
settings: 'Настройки',
|
||||
adPolicy: 'Реклама',
|
||||
betweenBlocks: 'Между блоками',
|
||||
betweenEpisodes: 'Между сериями',
|
||||
adsPerBreak: 'Роликов подряд',
|
||||
filler: 'Заглушка',
|
||||
noFiller: 'Без заглушки',
|
||||
shows: 'Шоу канала',
|
||||
show: 'Шоу',
|
||||
weight: 'Вес',
|
||||
block: 'Блок',
|
||||
on: 'Вкл',
|
||||
noShows: 'Шоу не добавлены',
|
||||
pickShow: 'Выберите шоу',
|
||||
blockCount: 'По сериям',
|
||||
blockDuration: 'По времени',
|
||||
episodes: 'серий',
|
||||
minutes: 'минут',
|
||||
ads: 'Реклама',
|
||||
pickAd: 'Выберите ролик',
|
||||
noAds: 'Пул рекламы пуст',
|
||||
overrides: 'Марафоны / override',
|
||||
modes: { Exclusive: 'Эксклюзив', Boost: 'Буст' },
|
||||
from: 'С',
|
||||
to: 'По',
|
||||
noOverrides: 'Override не заданы',
|
||||
schedule: 'Расписание (12 ч)',
|
||||
noSchedule: 'Расписание ещё не построено — нажмите «Пересобрать»',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -149,6 +224,15 @@ const resources = {
|
||||
placeholder: 'The channel list will show up here later — for now, enjoy the test card.',
|
||||
role: 'Role',
|
||||
},
|
||||
air: {
|
||||
now: 'Now',
|
||||
next: 'Up next',
|
||||
ad: 'Ad',
|
||||
episode: 'Episode',
|
||||
noChannels: 'No channels available yet. Check back later.',
|
||||
playbackError: 'Failed to play the stream',
|
||||
unsupported: 'This browser cannot play HLS',
|
||||
},
|
||||
settings: {
|
||||
title: 'Account settings',
|
||||
changeUserName: 'Change username',
|
||||
@@ -183,6 +267,72 @@ const resources = {
|
||||
unblock: 'Unblock',
|
||||
filterAll: 'All roles',
|
||||
},
|
||||
media: {
|
||||
title: 'Media',
|
||||
upload: 'Upload',
|
||||
uploaded: 'File uploaded, processing started',
|
||||
name: 'File',
|
||||
status: 'Status',
|
||||
duration: 'Duration',
|
||||
resolution: 'Resolution',
|
||||
empty: 'No uploaded files yet',
|
||||
statuses: {
|
||||
Pending: 'Queued',
|
||||
Processing: 'Processing',
|
||||
Ready: 'Ready',
|
||||
Failed: 'Failed',
|
||||
},
|
||||
},
|
||||
shows: {
|
||||
title: 'Shows',
|
||||
name: 'Name',
|
||||
kind: 'Kind',
|
||||
kinds: { Series: 'Series', Single: 'Movie' },
|
||||
episodes: 'Episodes',
|
||||
episode: 'Episode',
|
||||
addEpisode: 'Add episode',
|
||||
pickAsset: 'Pick a file',
|
||||
noEpisodes: 'No episodes yet',
|
||||
},
|
||||
channels: {
|
||||
title: 'Channels',
|
||||
name: 'Name',
|
||||
slug: 'Slug',
|
||||
state: 'State',
|
||||
enabled: 'On air',
|
||||
disabled: 'Off',
|
||||
enabledLabel: 'Channel on air',
|
||||
regenerate: 'Rebuild',
|
||||
regenerated: 'Schedule rebuilt',
|
||||
settings: 'Settings',
|
||||
adPolicy: 'Ads',
|
||||
betweenBlocks: 'Between blocks',
|
||||
betweenEpisodes: 'Between episodes',
|
||||
adsPerBreak: 'Ads per break',
|
||||
filler: 'Filler',
|
||||
noFiller: 'No filler',
|
||||
shows: 'Channel shows',
|
||||
show: 'Show',
|
||||
weight: 'Weight',
|
||||
block: 'Block',
|
||||
on: 'On',
|
||||
noShows: 'No shows added',
|
||||
pickShow: 'Pick a show',
|
||||
blockCount: 'By episodes',
|
||||
blockDuration: 'By time',
|
||||
episodes: 'episodes',
|
||||
minutes: 'minutes',
|
||||
ads: 'Ads',
|
||||
pickAd: 'Pick an ad',
|
||||
noAds: 'Ad pool is empty',
|
||||
overrides: 'Marathons / overrides',
|
||||
modes: { Exclusive: 'Exclusive', Boost: 'Boost' },
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
noOverrides: 'No overrides set',
|
||||
schedule: 'Schedule (12h)',
|
||||
noSchedule: 'Schedule not built yet — click “Rebuild”',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user