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

This commit is contained in:
Leonid Pershin
2026-07-24 16:18:54 +03:00
parent 1bbfd15907
commit a2685fb602
24 changed files with 2050 additions and 42 deletions
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/react-router": "^1.170.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.16",
"i18next": "^26.3.4",
"lucide-react": "^1.22.0",
"react": "^19.2.7",
+19 -11
View File
@@ -35,6 +35,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
hls.js:
specifier: ^1.6.16
version: 1.6.16
i18next:
specifier: ^26.3.4
version: 26.3.6(typescript@6.0.3)
@@ -68,7 +71,7 @@ importers:
version: 4.3.3(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
'@tanstack/router-plugin':
specifier: ^1.168.18
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))
'@types/node':
specifier: ^24.13.2
version: 24.13.3
@@ -1051,6 +1054,9 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
hls.js@1.6.16:
resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==}
html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
@@ -1605,7 +1611,7 @@ snapshots:
'@babel/compat-data@7.29.7': {}
'@babel/core@7.29.7':
'@babel/core@7.29.7(supports-color@10.2.2)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -1614,7 +1620,7 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.7
'@babel/template': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@10.2.2)
'@babel/types': 7.29.7
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
@@ -1645,17 +1651,17 @@ snapshots:
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@10.2.2)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/helper-module-imports': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
@@ -1682,7 +1688,7 @@ snapshots:
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@babel/traverse@7.29.7':
'@babel/traverse@7.29.7(supports-color@10.2.2)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.7
@@ -2266,9 +2272,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
'@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(rolldown@1.1.5)(supports-color@10.2.2)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0))':
dependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/template': 7.29.7
'@babel/types': 7.29.7
'@tanstack/router-core': 1.171.15
@@ -2343,9 +2349,9 @@ snapshots:
babel-dead-code-elimination@1.0.12:
dependencies:
'@babel/core': 7.29.7
'@babel/core': 7.29.7(supports-color@10.2.2)
'@babel/parser': 7.29.7
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@10.2.2)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -2424,6 +2430,8 @@ snapshots:
graceful-fs@4.2.11: {}
hls.js@1.6.16: {}
html-parse-stringify@4.0.1: {}
https-proxy-agent@7.0.6(supports-color@10.2.2):
@@ -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>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
MediaAssetDto,
MediaAssetStatus,
PagedList,
} from '@/shared/api/types'
export type ListMediaParams = {
page: number
pageSize: number
status?: MediaAssetStatus
search?: string
}
export function listMedia(params: ListMediaParams) {
const query = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
})
if (params.status) query.set('status', params.status)
if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
export function deleteMedia(id: string) {
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
}
/**
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
*/
export function uploadMedia(
file: File,
onProgress?: (percent: number) => void,
): Promise<CreatedIdResponse> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open('POST', `/api/admin/media?${query.toString()}`)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (event) => {
if (event.lengthComputable && onProgress)
onProgress(Math.round((event.loaded / event.total) * 100))
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
} else {
let detail = `HTTP ${xhr.status}`
try {
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
detail = problem.detail ?? problem.title ?? detail
} catch {
/* пусто */
}
reject(new HttpError({ detail }, xhr.status))
}
}
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
xhr.send(file)
})
}
@@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { addEpisode, getShow, removeEpisode } from './api'
export function ShowDetail({ showId }: { showId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [assetId, setAssetId] = useState('')
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const addMutation = useMutation({
mutationFn: () => addEpisode(showId, assetId),
onSuccess: () => {
setAssetId('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate,
onError,
})
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
const canAdd = show.kind !== 'Single' || show.episodes.length === 0
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<Button asChild size="sm" variant="ghost">
<Link to="/admin/shows">
<ChevronLeft className="h-4 w-4" />
{t('admin.shows.title')}
</Link>
</Button>
</div>
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{show.name}</h2>
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</div>
{canAdd && (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.shows.pickAsset')} />
</SelectTrigger>
<SelectContent>
{ready?.items.map((asset) => (
<SelectItem key={asset.id} value={asset.id}>
{asset.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || addMutation.isPending} onClick={() => addMutation.mutate()}>
{t('admin.shows.addEpisode')}
</Button>
</div>
)}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">#</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episode')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.duration')}</th>
<th className="px-4 py-2 font-medium">{t('admin.media.status')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{show.episodes.map((episode, index) => (
<tr key={episode.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">{episode.assetName ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">
{formatDuration(episode.durationSeconds)}
</td>
<td className="px-4 py-2">
{episode.assetStatus && (
<Badge variant={episode.assetStatus === 'Ready' ? 'default' : 'muted'}>
{t(`admin.media.statuses.${episode.assetStatus}`)}
</Badge>
)}
</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => removeMutation.mutate(episode.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
{show.episodes.length === 0 && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('admin.shows.noEpisodes')}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,113 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { createShow, deleteShow, listShows } from './api'
export function ShowsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [kind, setKind] = useState<ShowKind>('Series')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createShow({ name: name.trim(), kind }),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
const deleteMutation = useMutation({ mutationFn: deleteShow, onSuccess: invalidate, onError })
return (
<div className="flex flex-col gap-4">
<h2 className="crt-glow text-xl font-semibold">{t('admin.shows.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.shows.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Select value={kind} onValueChange={(v) => setKind(v as ShowKind)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Series">{t('admin.shows.kinds.Series')}</SelectItem>
<SelectItem value="Single">{t('admin.shows.kinds.Single')}</SelectItem>
</SelectContent>
</Select>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('admin.shows.name')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.kind')}</th>
<th className="px-4 py-2 font-medium">{t('admin.shows.episodes')}</th>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={4}>
{t('common.loading')}
</td>
</tr>
)}
{data?.map((show) => (
<tr key={show.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<Link
to="/admin/shows/$showId"
params={{ showId: show.id }}
className="text-primary hover:underline"
>
{show.name}
</Link>
</td>
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.kinds.${show.kind}`)}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(show.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, ShowDto, ShowKind, ShowSummaryDto } from '@/shared/api/types'
export function listShows() {
return apiRequest<ShowSummaryDto[]>('/admin/shows')
}
export function getShow(id: string) {
return apiRequest<ShowDto>(`/admin/shows/${id}`)
}
export function createShow(body: { name: string; kind: ShowKind; description?: string }) {
return apiRequest<CreatedIdResponse>('/admin/shows', { method: 'POST', body })
}
export function deleteShow(id: string) {
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
}
export function addEpisode(showId: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/shows/${showId}/episodes`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeEpisode(showId: string, episodeId: string) {
return apiRequest<void>(`/admin/shows/${showId}/episodes/${episodeId}`, { method: 'DELETE' })
}
+145
View File
@@ -0,0 +1,145 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Radio } from 'lucide-react'
import type { ScheduleEntryDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { ChannelPlayer } from './ChannelPlayer'
import { getEpg, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function AirPage() {
const { t } = useTranslation()
const [selected, setSelected] = useState<string | null>(null)
const [watchReady, setWatchReady] = useState(false)
const { data: channels, isLoading } = useQuery({
queryKey: ['air', 'channels'],
queryFn: listChannels,
})
useEffect(() => {
if (!selected && channels && channels.length > 0) setSelected(channels[0].slug)
}, [channels, selected])
useEffect(() => {
if (!selected) return
setWatchReady(false)
let cancelled = false
void watchChannel(selected)
.then(() => {
if (!cancelled) setWatchReady(true)
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [selected])
const { data: epg } = useQuery({
queryKey: ['air', 'epg', selected],
queryFn: () =>
getEpg(
selected!,
new Date(Date.now() - 30 * 60_000),
new Date(Date.now() + 3 * 60 * 60_000),
),
enabled: !!selected,
refetchInterval: 60_000,
})
const { current, upcoming } = useMemo(() => splitEpg(epg ?? []), [epg])
if (isLoading) return <p className="text-muted-foreground">{t('common.loading')}</p>
if (!channels || channels.length === 0)
return (
<div className="flex flex-col gap-2">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<p className="text-muted-foreground">{t('air.noChannels')}</p>
</div>
)
return (
<div className="flex flex-col gap-4">
<h1 className="crt-glow text-2xl font-bold">{t('nav.dashboard')}</h1>
<div className="grid gap-4 md:grid-cols-[220px_1fr]">
<aside className="flex gap-2 overflow-x-auto md:flex-col md:overflow-visible">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => setSelected(channel.slug)}
className={cn(
'flex shrink-0 items-center gap-2 rounded-sm border border-border px-3 py-2 text-left text-sm hover:bg-muted md:shrink',
selected === channel.slug && 'border-primary text-primary',
)}
>
<Radio className="h-4 w-4" />
{channel.name}
</button>
))}
</aside>
<div className="flex flex-col gap-4">
{selected && watchReady ? (
<ChannelPlayer slug={selected} />
) : (
<div className="aspect-video w-full animate-pulse rounded-md border border-border bg-black" />
)}
<div className="flex flex-col gap-3">
{current && (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge>{t('air.now')}</Badge>
<span className="font-medium">{programLabel(current, t)}</span>
</div>
<span className="text-xs text-muted-foreground">
{formatTime(current.startsAtUtc)} {formatTime(current.endsAtUtc)}
</span>
</div>
)}
{upcoming.length > 0 && (
<div className="crt-panel rounded-md">
<div className="border-b border-border px-4 py-2 text-xs uppercase tracking-wide text-muted-foreground">
{t('air.next')}
</div>
<ul className="divide-y divide-border">
{upcoming.slice(0, 6).map((entry) => (
<li key={entry.id} className="flex items-center gap-3 px-4 py-2 text-sm">
<span className="w-12 shrink-0 text-muted-foreground">
{formatTime(entry.startsAtUtc)}
</span>
<span>{programLabel(entry, t)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
function splitEpg(entries: ScheduleEntryDto[]) {
const now = Date.now()
const current = entries.find(
(e) => new Date(e.startsAtUtc).getTime() <= now && new Date(e.endsAtUtc).getTime() > now,
)
const upcoming = entries.filter((e) => new Date(e.startsAtUtc).getTime() > now)
return { current, upcoming }
}
function programLabel(entry: ScheduleEntryDto, t: (key: string) => string) {
if (entry.kind === 'Ad') return t('air.ad')
const name = entry.showName ?? '—'
return entry.episodeIndex != null ? `${name} · ${t('air.episode')} ${entry.episodeIndex + 1}` : name
}
@@ -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>
)
}
+19
View File
@@ -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}` : ''}`)
}
+125
View File
@@ -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,
}
+22 -1
View File
@@ -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} />
}
+4
View File
@@ -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 -1
View File
@@ -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" />,
})
+4
View File
@@ -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} />
}
+4
View File
@@ -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 })
+4 -29
View File
@@ -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 />
}
+117
View File
@@ -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 }
+150
View File
@@ -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”',
},
},
},
},