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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user