Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -1,197 +1,186 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api'
|
||||
import { AddAdForm } from './components/AddAdForm'
|
||||
import { AddShowForm } from './components/AddShowForm'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { ChannelShowRow } from './components/ChannelShowRow'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { RemoveButton } from './components/fields'
|
||||
import { OverrideForm } from './components/OverrideForm'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { formatMinute, formatTime } from './lib/format'
|
||||
|
||||
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', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['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}
|
||||
/>
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{/* Шоу канала */}
|
||||
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="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>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Реклама */}
|
||||
<CollapsibleCard title={t('admin.channels.ads')} contentClassName="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}
|
||||
/>
|
||||
{ready?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
|
||||
)}
|
||||
<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>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="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>{' '}
|
||||
{o.recurrence === 'Weekly'
|
||||
? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}`
|
||||
: `${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>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Предпросмотр расписания */}
|
||||
<CollapsibleCard title={t('admin.channels.schedule')}>
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, Plus, Send } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
createLayer,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: template } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'template'],
|
||||
queryFn: () => getChannelTemplate(channelId),
|
||||
})
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['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 onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => applyChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||
for (const warning of result.warnings)
|
||||
toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addLayerMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
|
||||
return createLayer(template!.id, {
|
||||
name: t('admin.channels.newLayerName'),
|
||||
priority: nextPriority,
|
||||
})
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const deleteLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const layerForNewSlot =
|
||||
activeLayerId ?? template?.layers.find((l) => !l.isBackground)?.id ?? template?.layers[0]?.id
|
||||
|
||||
const openNewSlot = (weekday: number, startMinutes: number) => {
|
||||
if (!layerForNewSlot) return
|
||||
const hh = Math.floor(startMinutes / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mm = (startMinutes % 60).toString().padStart(2, '0')
|
||||
setDraft({
|
||||
layerId: layerForNewSlot,
|
||||
slot: null,
|
||||
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
|
||||
})
|
||||
}
|
||||
|
||||
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/channels">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
{channel.number !== null && <Badge variant="muted">№ {channel.number}</Badge>}
|
||||
{!channel.isEnabled && <Badge variant="muted">{t('admin.channels.disabled')}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правка правил эфира не двигает — применение отдельной кнопкой. */}
|
||||
{template?.hasPendingChanges && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
||||
<span>{t('admin.channels.pendingChanges')}</span>
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
channel={channel}
|
||||
readyAssets={ready?.items ?? []}
|
||||
onSaved={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{template && (
|
||||
<CollapsibleCard title={t('admin.channels.grid')} defaultOpen>
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.layers')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => addLayerMutation.mutate()}
|
||||
disabled={addLayerMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LayerList
|
||||
template={template}
|
||||
activeLayerId={layerForNewSlot ?? null}
|
||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
onSelectSlot={openSlot}
|
||||
onAddSlot={openNewSlot}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={invalidate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,245 +1,244 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
HourWindow,
|
||||
OverrideMode,
|
||||
OverrideRecurrence,
|
||||
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
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
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
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
},
|
||||
) {
|
||||
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 BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} 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)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
mode: OverrideMode
|
||||
recurrence: OverrideRecurrence
|
||||
startsAtUtc?: string | null
|
||||
endsAtUtc?: string | null
|
||||
dayOfWeek?: number | null
|
||||
startMinute?: number | null
|
||||
endMinute?: number | null
|
||||
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()}`)
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
LayerApplicability,
|
||||
ScheduleEntryDto,
|
||||
ScheduleTemplateDto,
|
||||
SlotDto,
|
||||
} 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
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||
export function updateChannelTime(
|
||||
id: string,
|
||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getChannelTemplate(channelId: string) {
|
||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||
}
|
||||
|
||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||
export function applyChannelTemplate(channelId: string) {
|
||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: { name: string; fallbackGroupId: string | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLayer(
|
||||
layerId: string,
|
||||
body: {
|
||||
name: string
|
||||
priority: number
|
||||
applicability: LayerApplicability | null
|
||||
isEnabled: boolean
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteLayer(layerId: string) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: string, body: SlotBody) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} 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)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
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()}`)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelAd } from '../api'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelShow } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +1,179 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
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 { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
adInsertion: channel.adInsertion,
|
||||
adsPerBreak: channel.adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
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 { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode, ChannelShowDto, HourWindow } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { removeChannelShow, updateChannelShow } from '../api'
|
||||
import { RemoveButton } from './fields'
|
||||
|
||||
export 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 [expanded, setExpanded] = useState(false)
|
||||
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, {
|
||||
weight,
|
||||
blockMode,
|
||||
blockValue,
|
||||
isEnabled,
|
||||
preferredWeightMultiplier: multiplier,
|
||||
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||
|
||||
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-36 whitespace-nowrap">
|
||||
<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 flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{t('admin.channels.preferredHours')}
|
||||
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||
</Button>
|
||||
<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>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td colSpan={5} className="bg-muted/30 py-3">
|
||||
<div className="flex flex-col gap-3 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">{t('admin.channels.preferredMultiplier')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.preferredHoursHint')}
|
||||
</span>
|
||||
</div>
|
||||
{hours.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||
)}
|
||||
{hours.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<HourSelect value={w.startHour} from={0} to={23} onChange={(v) => setHour(i, { startHour: v })} />
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<HourSelect value={w.endHour} from={1} to={24} onChange={(v) => setHour(i, { endHour: v })} />
|
||||
{w.startHour >= w.endHour && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('admin.channels.preferredBadRange')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={addHour}>
|
||||
{t('admin.channels.preferredAddWindow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||
function HourSelect({
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
from: number
|
||||
to: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
return (
|
||||
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OverrideMode, OverrideRecurrence } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { createOverride } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export 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 [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
|
||||
const [dayOfWeek, setDayOfWeek] = useState(6)
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
|
||||
const toMinutes = (hhmm: string) => {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const weekly = recurrence === 'Weekly'
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createOverride(
|
||||
channelId,
|
||||
weekly
|
||||
? {
|
||||
mode,
|
||||
recurrence,
|
||||
dayOfWeek,
|
||||
startMinute: toMinutes(startTime),
|
||||
endMinute: toMinutes(endTime),
|
||||
shows: [{ showId, weight }],
|
||||
}
|
||||
: {
|
||||
mode,
|
||||
recurrence,
|
||||
startsAtUtc: new Date(start).toISOString(),
|
||||
endsAtUtc: new Date(end).toISOString(),
|
||||
shows: [{ showId, weight }],
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
setStartTime('')
|
||||
setEndTime('')
|
||||
onCreated()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const valid = weekly
|
||||
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
|
||||
: showId && start && end && new Date(end) > new Date(start)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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} />
|
||||
)}
|
||||
{weekly ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{t(`admin.channels.weekdays.${d}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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-60" />
|
||||
</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-60" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Anchor, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
const HOUR_HEIGHT = 44
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||
const DAYPART_CLASS: Record<string, string> = {
|
||||
Morning: 'bg-amber-500/20 border-amber-500/40',
|
||||
Day: 'bg-sky-500/20 border-sky-500/40',
|
||||
Prime: 'bg-violet-500/25 border-violet-500/50',
|
||||
Night: 'bg-slate-500/20 border-slate-500/40',
|
||||
}
|
||||
|
||||
function minutesOf(time: string): number {
|
||||
const [h, m] = time.split(':')
|
||||
return Number(h) * 60 + Number(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
||||
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
||||
*/
|
||||
function offsetInDay(slotStart: string, dayStart: string): number {
|
||||
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
||||
return diff >= 0 ? diff : diff + 24 * 60
|
||||
}
|
||||
|
||||
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
||||
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
||||
return layers
|
||||
.filter((layer) => layer.isEnabled)
|
||||
.flatMap((layer) =>
|
||||
layer.slots
|
||||
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
||||
.map((slot) => ({ slot, layer })),
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleGrid({
|
||||
template,
|
||||
selectedSlotId,
|
||||
onSelectSlot,
|
||||
onAddSlot,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
selectedSlotId: string | null
|
||||
onSelectSlot: (slot: SlotDto) => void
|
||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const dayStart = template.dayStartTime.slice(0, 5)
|
||||
const dayStartMinutes = minutesOf(dayStart)
|
||||
|
||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||
|
||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
|
||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const to = from + slot.targetDurationMinutes
|
||||
return ordered
|
||||
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
||||
.some((other) =>
|
||||
other.slots
|
||||
.filter((s) => s.weekday === null || s.weekday === weekday)
|
||||
.some((s) => {
|
||||
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
||||
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||
<div className="px-2 py-1">{dayStart}</div>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div key={weekday} className="px-2 py-1 text-center font-medium">
|
||||
{t(`admin.channels.weekdays.${weekday}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||
<div>
|
||||
{hours.map((hour, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className="relative border-l border-border"
|
||||
style={{ height: HOUR_HEIGHT * 24 }}
|
||||
>
|
||||
{hours.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
title={t('admin.channels.addSlotHere')}
|
||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
||||
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
||||
>
|
||||
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const covered = isCovered(slot, layer, weekday)
|
||||
return (
|
||||
<button
|
||||
key={`${slot.id}-${weekday}`}
|
||||
type="button"
|
||||
onClick={() => onSelectSlot(slot)}
|
||||
className={cn(
|
||||
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||
)}
|
||||
style={{
|
||||
top: (from / 60) * HOUR_HEIGHT,
|
||||
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||
{slot.targetStart.slice(0, 5)}
|
||||
</span>
|
||||
<span className="block truncate">{slot.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
|
||||
export function LayerList({
|
||||
template,
|
||||
activeLayerId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
activeLayerId: string | null
|
||||
onSelect: (layer: GridLayerDto) => void
|
||||
onDelete: (layer: GridLayerDto) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{ordered.map((layer) => (
|
||||
<li key={layer.id} className="flex items-center gap-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left',
|
||||
activeLayerId === layer.id && 'text-primary',
|
||||
)}
|
||||
onClick={() => onSelect(layer)}
|
||||
>
|
||||
{layer.name}
|
||||
</button>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.isBackground ? t('admin.channels.background') : layer.priority}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.slots.length}
|
||||
</span>
|
||||
{!layer.isBackground && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||
×
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +1,133 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AdInsertion, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
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 { updateChannelSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: 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,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="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>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
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 { updateChannelSettings, updateChannelTime } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: 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 [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||
mutationFn: async () => {
|
||||
await updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||
dayStartTime: `${dayStart}:00`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="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.number')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={number}
|
||||
placeholder={t('admin.channels.numberPlaceholder')}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-12}
|
||||
max={14}
|
||||
step={1}
|
||||
value={offsetHours}
|
||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.dayStart')}</Label>
|
||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||
</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>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
SlotBlockMode,
|
||||
SlotDto,
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createSlot, deleteSlot, updateSlot, type SlotBody } from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
|
||||
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
|
||||
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
|
||||
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
|
||||
|
||||
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
||||
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
||||
|
||||
function toBody(slot: SlotDto): SlotBody {
|
||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||
return body
|
||||
}
|
||||
|
||||
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||
return {
|
||||
title: '',
|
||||
weekday: null,
|
||||
targetStart: '20:00:00',
|
||||
targetDurationMinutes: 60,
|
||||
daypart: 'Day',
|
||||
slotKind: 'Content',
|
||||
groupId: null,
|
||||
strategy: {
|
||||
type: 'Sequential',
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
},
|
||||
repeatSource: null,
|
||||
blockMode: 'FillSlot',
|
||||
blockValue: 1,
|
||||
overflowPolicy: 'ContinueNext',
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<SlotBody>(() =>
|
||||
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (draft.slot) await updateSlot(draft.slot.id, body)
|
||||
else await createSlot(draft.layerId, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteSlot(draft.slot!.id),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
const isContent = body.slotKind === 'Content'
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.weekday ?? 'any'}
|
||||
onChange={(e) =>
|
||||
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="any">{t('admin.channels.everyDay')}</option>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.group')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.groupId ?? ''}
|
||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.strategy')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.strategy?.type ?? 'Sequential'}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: {
|
||||
...(body.strategy ?? {
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
type: 'Sequential',
|
||||
}),
|
||||
type: e.target.value as SlotStrategyType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{STRATEGIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.strategies.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{body.strategy?.type === 'RandomWithCooldown' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.cooldownDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={body.strategy.cooldownDays}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.overflowPolicy}
|
||||
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
|
||||
>
|
||||
{OVERFLOW.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.overflows.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.daysAgo}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatTime')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.repeatSource.time.slice(0, 5)}
|
||||
onChange={(e) =>
|
||||
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.durationMinutes}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: {
|
||||
...body.repeatSource!,
|
||||
durationMinutes: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={body.isAnchor}
|
||||
onChange={(e) => patch({ isAnchor: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.anchor')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxDrift')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={body.maxDriftMinutes}
|
||||
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.snap')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.snapToMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{SNAP_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, GripVertical, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
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 { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
addCollectionShow,
|
||||
getCollection,
|
||||
removeCollectionShow,
|
||||
reorderCollection,
|
||||
setCollectionPoster,
|
||||
updateCollection,
|
||||
} from './api'
|
||||
|
||||
export function CollectionDetail({ collectionId }: { collectionId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
const [pendingShow, setPendingShow] = useState('')
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [description, setDescription] = useState<string | null>(null)
|
||||
|
||||
const { data: collection, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'collections', collectionId],
|
||||
queryFn: () => getCollection(collectionId),
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateCollection(collectionId, {
|
||||
name: (name ?? collection?.name ?? '').trim(),
|
||||
description: description ?? collection?.description ?? null,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const posterMutation = useMutation({
|
||||
mutationFn: (imageId: string | null) => setCollectionPoster(collectionId, imageId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (showId: string) => addCollectionShow(collectionId, showId),
|
||||
onSuccess: () => {
|
||||
setPendingShow('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (showId: string) => removeCollectionShow(collectionId, showId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderCollection(collectionId, order),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !collection)
|
||||
return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const memberIds = new Set(collection.items.map((i) => i.showId))
|
||||
const available = (shows ?? []).filter((s) => !memberIds.has(s.id))
|
||||
|
||||
/** Порядок пересобирается целиком и уходит одним запросом — сервер сам расставит позиции. */
|
||||
const dropOn = (targetShowId: string) => {
|
||||
if (!dragged || dragged === targetShowId) return
|
||||
const order = collection.items.map((i) => i.showId).filter((id) => id !== dragged)
|
||||
const at = order.indexOf(targetShowId)
|
||||
order.splice(at, 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/collections">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.collections.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
{collection.posterImageId ? (
|
||||
<img
|
||||
src={imageUrl(collection.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.metadata.pickPoster')}
|
||||
</Button>
|
||||
{collection.posterImageId && (
|
||||
<Button size="sm" variant="ghost" onClick={() => posterMutation.mutate(null)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="ShowPoster"
|
||||
onSelect={(img) => posterMutation.mutate(img.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.collections.name')}</Label>
|
||||
<Input
|
||||
value={name ?? collection.name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.collections.description')}</Label>
|
||||
<Input
|
||||
value={description ?? collection.description ?? ''}
|
||||
maxLength={2048}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.collections.parts')}
|
||||
</h3>
|
||||
<Select value={pendingShow} onValueChange={setPendingShow}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder={t('admin.collections.addShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{available.map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!pendingShow || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate(pendingShow)}
|
||||
>
|
||||
{t('admin.collections.addShow')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.collections.orderHint')}</p>
|
||||
|
||||
<div className="crt-panel rounded-md">
|
||||
{collection.items.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('admin.collections.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{collection.items.map((item, index) => (
|
||||
<li
|
||||
key={item.showId}
|
||||
draggable
|
||||
onDragStart={() => setDragged(item.showId)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(item.showId)}
|
||||
className="flex items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||
<Link
|
||||
to="/admin/shows/$showId"
|
||||
params={{ showId: item.showId }}
|
||||
className="min-w-0 flex-1 truncate text-primary hover:underline"
|
||||
>
|
||||
{item.showName}
|
||||
</Link>
|
||||
{item.year && <span className="text-muted-foreground">{item.year}</span>}
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${item.showKind}`)}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.shows.episodes')}: {item.episodeCount}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeMutation.mutate(item.showId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createCollection, deleteCollection, listCollections } from './api'
|
||||
|
||||
export function CollectionsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'collections'],
|
||||
queryFn: listCollections,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createCollection({ name: name.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCollection,
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
name: (c) => c.name.toLowerCase(),
|
||||
items: (c) => c.itemCount,
|
||||
units: (c) => c.unitCount,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.collections.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.collections.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.collections.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(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="w-16 px-4 py-2 font-medium" />
|
||||
<SortHeader
|
||||
label={t('admin.collections.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.collections.parts')}
|
||||
sortKey="items"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.collections.units')}
|
||||
sortKey="units"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{rows.map((collection) => (
|
||||
<tr key={collection.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex h-12 w-8 items-center justify-center overflow-hidden rounded border border-border bg-muted/30">
|
||||
{collection.posterImageId && (
|
||||
<img
|
||||
src={imageUrl(collection.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to="/admin/collections/$collectionId"
|
||||
params={{ collectionId: collection.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{collection.itemCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{collection.unitCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(collection.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types'
|
||||
|
||||
export function listCollections() {
|
||||
return apiRequest<CollectionSummaryDto[]>('/admin/collections')
|
||||
}
|
||||
|
||||
export function getCollection(id: string) {
|
||||
return apiRequest<CollectionDto>(`/admin/collections/${id}`)
|
||||
}
|
||||
|
||||
export function createCollection(body: { name: string; description?: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/collections', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateCollection(id: string, body: { name: string; description: string | null }) {
|
||||
return apiRequest<void>(`/admin/collections/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteCollection(id: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addCollectionShow(id: string, showId: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
|
||||
}
|
||||
|
||||
export function removeCollectionShow(id: string, showId: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/shows/${showId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Порядок частей: не упомянутые остаются после перечисленных. */
|
||||
export function reorderCollection(id: string, showIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/order`, {
|
||||
method: 'PUT',
|
||||
body: { showIdsInOrder },
|
||||
})
|
||||
}
|
||||
|
||||
/** Привязать/снять постер коллекции (null — отвязать). */
|
||||
export function setCollectionPoster(id: string, imageId: string | null) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/poster-image`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GenreDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9-]+$/),
|
||||
aliases: z.string().max(1024).optional(),
|
||||
})
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
sortOrder: z.number().int().min(0),
|
||||
aliases: z.string().max(1024).optional(),
|
||||
})
|
||||
|
||||
type CreateValues = z.infer<typeof createSchema>
|
||||
type EditValues = z.infer<typeof editSchema>
|
||||
|
||||
/** Псевдонимы вводятся одной строкой через запятую — их редко больше десятка. */
|
||||
function parseAliases(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
export function GenresPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: genres, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'genres'],
|
||||
queryFn: listGenres,
|
||||
})
|
||||
const { sort, toggle } = useTableSort('sortOrder', false)
|
||||
const sortedGenres = sortRows(genres ?? [], sort, {
|
||||
sortOrder: (g) => g.sortOrder,
|
||||
name: (g) => g.name.toLowerCase(),
|
||||
slug: (g) => g.slug,
|
||||
showCount: (g) => g.showCount,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'genres'] })
|
||||
const reportError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...body
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
aliases: string[]
|
||||
}) => updateGenre(id, body),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteGenre,
|
||||
onSuccess: invalidate,
|
||||
onError: reportError,
|
||||
})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<GenreDto | null>(null)
|
||||
|
||||
const createForm = useForm<CreateValues>({ resolver: zodResolver(createSchema) })
|
||||
const editForm = useForm<EditValues>({ resolver: zodResolver(editSchema) })
|
||||
|
||||
const openEdit = (genre: GenreDto) => {
|
||||
setEditing(genre)
|
||||
editForm.reset({
|
||||
name: genre.name,
|
||||
sortOrder: genre.sortOrder,
|
||||
aliases: genre.aliases.join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
const onCreate = async (values: CreateValues) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
aliases: parseAliases(values.aliases),
|
||||
})
|
||||
createForm.reset()
|
||||
setCreateOpen(false)
|
||||
} catch (error) {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const onEdit = async (values: EditValues) => {
|
||||
if (!editing) return
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editing.id,
|
||||
name: values.name,
|
||||
sortOrder: values.sortOrder,
|
||||
aliases: parseAliases(values.aliases),
|
||||
})
|
||||
setEditing(null)
|
||||
} catch (error) {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.genres.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.genres.hint')}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> {t('admin.genres.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>
|
||||
<SortHeader
|
||||
label={t('admin.genres.order')}
|
||||
sortKey="sortOrder"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.genres.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.genres.slug')}
|
||||
sortKey="slug"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.genres.aliases')}</th>
|
||||
<SortHeader
|
||||
label={t('admin.genres.usage')}
|
||||
sortKey="showCount"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<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={6}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedGenres.map((genre) => (
|
||||
<tr key={genre.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2 text-muted-foreground">{genre.sortOrder}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{genre.name}
|
||||
{genre.isSystem && <Badge variant="muted">{t('admin.genres.system')}</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{genre.slug}</td>
|
||||
<td className="max-w-md px-4 py-2 text-xs text-muted-foreground">
|
||||
{genre.aliases.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-2">{genre.showCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(genre)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={genre.isSystem || genre.showCount > 0}
|
||||
onClick={() => deleteMutation.mutate(genre.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.genres.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={createForm.handleSubmit(onCreate)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreName">{t('admin.genres.name')}</Label>
|
||||
<Input id="genreName" {...createForm.register('name')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreSlug">{t('admin.genres.slug')}</Label>
|
||||
<Input id="genreSlug" placeholder="action" {...createForm.register('slug')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.slugHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreAliases">{t('admin.genres.aliases')}</Label>
|
||||
<Input
|
||||
id="genreAliases"
|
||||
placeholder="tmdb:28, action"
|
||||
{...createForm.register('aliases')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.genres.edit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={editForm.handleSubmit(onEdit)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreName">{t('admin.genres.name')}</Label>
|
||||
<Input id="editGenreName" {...editForm.register('name')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreOrder">{t('admin.genres.order')}</Label>
|
||||
<Input
|
||||
id="editGenreOrder"
|
||||
type="number"
|
||||
{...editForm.register('sortOrder', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreAliases">{t('admin.genres.aliases')}</Label>
|
||||
<Input id="editGenreAliases" {...editForm.register('aliases')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CreatedIdResponse, GenreDto } from '@/shared/api/types'
|
||||
|
||||
export function listGenres() {
|
||||
return apiRequest<GenreDto[]>('/admin/genres')
|
||||
}
|
||||
|
||||
export function createGenre(body: { name: string; slug: string; aliases?: string[] }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/genres', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateGenre(
|
||||
id: string,
|
||||
body: { name: string; sortOrder: number; aliases?: string[] },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteGenre(id: string) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { splitDuration } from './format'
|
||||
|
||||
/** Объём эфира: «118 ч 40 мин». Ноль — прочерк, потому что «0 ч» читается как сбой подсчёта. */
|
||||
export function DurationLabel({ seconds }: { seconds: number }) {
|
||||
const { t } = useTranslation()
|
||||
const parts = splitDuration(seconds)
|
||||
if (!parts) return <>—</>
|
||||
|
||||
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.groups.hoursShort')}` : ''
|
||||
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.groups.minutesShort')}` : ''
|
||||
return <>{[hours, minutes].filter(Boolean).join(' ')}</>
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
addGroupElements,
|
||||
findGroupCandidates,
|
||||
getGroup,
|
||||
removeGroupItem,
|
||||
reorderGroup,
|
||||
setGroupItemWeight,
|
||||
updateGroup,
|
||||
} from './api'
|
||||
import { DurationLabel } from './DurationLabel'
|
||||
import { GroupFilterPanel } from './GroupFilterPanel'
|
||||
|
||||
const EMPTY_FILTER: GroupFilter = {}
|
||||
|
||||
export function GroupDetail({ groupId }: { groupId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [description, setDescription] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<GroupFilter | null>(null)
|
||||
const [candidates, setCandidates] = useState<GroupCandidateDto[] | null>(null)
|
||||
const [showWeights, setShowWeights] = useState(false)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
|
||||
const { data: group, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'groups', groupId],
|
||||
queryFn: () => getGroup(groupId),
|
||||
})
|
||||
|
||||
// Черновик правила поднимаем из сохранённого один раз — дальше им владеет форма.
|
||||
useEffect(() => {
|
||||
if (group && filter === null) setFilter(group.filter ?? EMPTY_FILTER)
|
||||
}, [group, filter])
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateGroup(groupId, {
|
||||
name: (name ?? group?.name ?? '').trim(),
|
||||
description: description ?? group?.description ?? null,
|
||||
filter: filter ?? null,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const findMutation = useMutation({
|
||||
mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER),
|
||||
onSuccess: setCandidates,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (elements: GroupCandidateDto[]) =>
|
||||
addGroupElements(
|
||||
groupId,
|
||||
elements.map((c) => ({ elementKind: c.elementKind, elementId: c.elementId })),
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.groups.added', { count: result.added }))
|
||||
setCandidates(null)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (itemId: string) => removeGroupItem(groupId, itemId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const weightMutation = useMutation({
|
||||
mutationFn: ({ itemId, weight }: { itemId: string; weight: number }) =>
|
||||
setGroupItemWeight(groupId, itemId, weight),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderGroup(groupId, order),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !group) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const dropOn = (targetItemId: string) => {
|
||||
if (!dragged || dragged === targetItemId) return
|
||||
const order = group.items.map((i) => i.id).filter((id) => id !== dragged)
|
||||
order.splice(order.indexOf(targetItemId), 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/groups">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.groups.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.name')}</Label>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={name ?? group.name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.description')}</Label>
|
||||
<Input
|
||||
value={description ?? group.description ?? ''}
|
||||
maxLength={2048}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="muted">
|
||||
{t('admin.groups.items')}: {group.itemCount}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
{t('admin.groups.units')}: {group.unitCount}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
<DurationLabel seconds={group.totalDurationSeconds} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Левая панель — конструктор правила набора */}
|
||||
<div className="crt-panel flex flex-col gap-4 rounded-md p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{t('admin.groups.filter.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.hint')}</p>
|
||||
</div>
|
||||
|
||||
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={findMutation.isPending} onClick={() => findMutation.mutate()}>
|
||||
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
||||
</Button>
|
||||
{candidates !== null && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={fresh.length === 0 || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate(fresh)}
|
||||
>
|
||||
{t('admin.groups.addFound')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{candidates !== null && candidates.length > 0 && (
|
||||
<ul className="max-h-72 divide-y divide-border overflow-y-auto rounded border border-border text-sm">
|
||||
{candidates.map((candidate) => (
|
||||
<li
|
||||
key={`${candidate.elementKind}:${candidate.elementId}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{candidate.elementName}</span>
|
||||
{candidate.year && (
|
||||
<span className="text-muted-foreground">{candidate.year}</span>
|
||||
)}
|
||||
<Badge variant="muted">
|
||||
{t(`admin.groups.elementKinds.${candidate.elementKind}`)}
|
||||
</Badge>
|
||||
{candidate.alreadyInGroup && (
|
||||
<Badge variant="muted">{t('admin.groups.alreadyIn')}</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Правая панель — состав */}
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{t('admin.groups.composition')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowWeights((v) => !v)}>
|
||||
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.orderHint')}</p>
|
||||
|
||||
{group.items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{group.items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={() => setDragged(item.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(item.id)}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
|
||||
<Badge variant="muted">
|
||||
{t(`admin.groups.elementKinds.${item.elementKind}`)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.groups.units')}: {item.unitCount}
|
||||
</span>
|
||||
{showWeights && (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-20"
|
||||
defaultValue={item.weight}
|
||||
onBlur={(e) => {
|
||||
const weight = Number(e.target.value)
|
||||
if (Number.isFinite(weight) && weight !== item.weight)
|
||||
weightMutation.mutate({ itemId: item.id, weight })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeMutation.mutate(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES } from '@/shared/api/types'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
const ELEMENT_KINDS: GroupElementKind[] = ['Show', 'Collection']
|
||||
const SHOW_KINDS: ShowKind[] = ['Series', 'Single']
|
||||
|
||||
/** Конструктор правила набора. Правило не применяется само — оно только ищет кандидатов. */
|
||||
export function GroupFilterPanel({
|
||||
filter,
|
||||
onChange,
|
||||
}: {
|
||||
filter: GroupFilter
|
||||
onChange: (next: GroupFilter) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
|
||||
|
||||
const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part })
|
||||
|
||||
const toggleIn = <T,>(list: T[] | null | undefined, value: T): T[] => {
|
||||
const current = list ?? []
|
||||
return current.includes(value) ? current.filter((x) => x !== value) : [...current, value]
|
||||
}
|
||||
|
||||
// Пустая строка означает «не ограничивать», поэтому 0 и «не задано» различаются явно.
|
||||
const numberOrNull = (value: string) => (value.trim() === '' ? null : Number(value))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.elementKinds')}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ELEMENT_KINDS.map((kind) => (
|
||||
<label key={kind} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.elementKinds ?? []).includes(kind)}
|
||||
onChange={() => patch({ elementKinds: toggleIn(filter.elementKinds, kind) })}
|
||||
/>
|
||||
{t(`admin.groups.elementKinds.${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.showKinds')}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SHOW_KINDS.map((kind) => (
|
||||
<label key={kind} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.showKinds ?? []).includes(kind)}
|
||||
onChange={() => patch({ showKinds: toggleIn(filter.showKinds, kind) })}
|
||||
/>
|
||||
{t(`admin.shows.kinds.${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.genres')}</Label>
|
||||
<div className="flex max-h-40 flex-wrap gap-x-3 gap-y-1 overflow-y-auto">
|
||||
{(genres ?? []).map((genre) => (
|
||||
<label key={genre.id} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.genreIds ?? []).includes(genre.id)}
|
||||
onChange={() => patch({ genreIds: toggleIn(filter.genreIds, genre.id) })}
|
||||
/>
|
||||
{genre.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.genresHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.maxAudience')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={filter.maxAudience ?? ''}
|
||||
onChange={(e) =>
|
||||
patch({ maxAudience: e.target.value === '' ? null : (e.target.value as ShowAudience) })
|
||||
}
|
||||
>
|
||||
<option value="">{t('admin.groups.filter.any')}</option>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.year')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.from')}
|
||||
value={filter.yearMin ?? ''}
|
||||
onChange={(e) => patch({ yearMin: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.to')}
|
||||
value={filter.yearMax ?? ''}
|
||||
onChange={(e) => patch({ yearMax: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.unitMinutes')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.from')}
|
||||
value={filter.unitMinutesMin ?? ''}
|
||||
onChange={(e) => patch({ unitMinutesMin: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.to')}
|
||||
value={filter.unitMinutesMax ?? ''}
|
||||
onChange={(e) => patch({ unitMinutesMax: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.unitMinutesHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
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 { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createGroup, deleteGroup, listGroups } from './api'
|
||||
import { DurationLabel } from './DurationLabel'
|
||||
|
||||
export function GroupsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup({ name: name.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError })
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
name: (g) => g.name.toLowerCase(),
|
||||
items: (g) => g.itemCount,
|
||||
units: (g) => g.unitCount,
|
||||
duration: (g) => g.totalDurationSeconds,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.groups.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.groups.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(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>
|
||||
<SortHeader
|
||||
label={t('admin.groups.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.items')}
|
||||
sortKey="items"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.units')}
|
||||
sortKey="units"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.duration')}
|
||||
sortKey="duration"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
{rows.map((group) => (
|
||||
<tr key={group.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/admin/groups/$groupId"
|
||||
params={{ groupId: group.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
{group.hasFilter && (
|
||||
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{group.itemCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{group.unitCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
<DurationLabel seconds={group.totalDurationSeconds} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(group.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
GroupCandidateDto,
|
||||
GroupDto,
|
||||
GroupElementKind,
|
||||
GroupFilter,
|
||||
GroupSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listGroups() {
|
||||
return apiRequest<GroupSummaryDto[]>('/admin/groups')
|
||||
}
|
||||
|
||||
export function getGroup(id: string) {
|
||||
return apiRequest<GroupDto>(`/admin/groups/${id}`)
|
||||
}
|
||||
|
||||
export function createGroup(body: { name: string; description?: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/groups', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateGroup(
|
||||
id: string,
|
||||
body: { name: string; description: string | null; filter: GroupFilter | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/groups/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string) {
|
||||
return apiRequest<void>(`/admin/groups/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Подбор по правилу набора. Фильтр передаётся явно — редактор крутит его до сохранения. */
|
||||
export function findGroupCandidates(id: string, filter: GroupFilter | null) {
|
||||
return apiRequest<GroupCandidateDto[]>(`/admin/groups/${id}/candidates`, {
|
||||
method: 'POST',
|
||||
body: { filter },
|
||||
})
|
||||
}
|
||||
|
||||
export function addGroupElements(
|
||||
id: string,
|
||||
elements: { elementKind: GroupElementKind; elementId: string }[],
|
||||
) {
|
||||
return apiRequest<{ added: number }>(`/admin/groups/${id}/items`, {
|
||||
method: 'POST',
|
||||
body: { elements },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeGroupItem(id: string, itemId: string) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function setGroupItemWeight(id: string, itemId: string, weight: number) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}/weight`, {
|
||||
method: 'PUT',
|
||||
body: { weight },
|
||||
})
|
||||
}
|
||||
|
||||
/** Порядок позиций: не упомянутые остаются после перечисленных. */
|
||||
export function reorderGroup(id: string, itemIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/order`, {
|
||||
method: 'PUT',
|
||||
body: { itemIdsInOrder },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Разбивает объём эфира на часы и минуты. Единицы измерения подставляет вызывающий из переводов —
|
||||
* функция намеренно не знает языка.
|
||||
*/
|
||||
export function splitDuration(totalSeconds: number): { hours: number; minutes: number } | null {
|
||||
if (!totalSeconds || totalSeconds <= 0) return null
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function MaintenancePanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showId, setShowId] = useState('')
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
@@ -30,7 +30,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
|
||||
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { MediaAssetDto, ShowAudience } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseEpisodeName,
|
||||
} from '@/features/admin/media/episode-parse'
|
||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
||||
import { ShowGenresField } from './ShowGenresField'
|
||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { addEpisode, getShow, removeEpisode, setShowAudience } from './api'
|
||||
@@ -156,9 +157,11 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
|
||||
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
|
||||
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{show.kind === 'Series' && (
|
||||
@@ -175,6 +178,24 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
{t('admin.shows.loadedSeasons')}: {seasons.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<ShowGenresField show={show} onChanged={invalidate} />
|
||||
</div>
|
||||
{show.collections.length > 0 && (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{t('admin.shows.inCollections')}:
|
||||
{show.collections.map((collection) => (
|
||||
<Link
|
||||
key={collection.id}
|
||||
to="/admin/collections/$collectionId"
|
||||
params={{ collectionId: collection.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShowMetadataCard show={show} onChanged={invalidate} />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Tag } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ShowDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { setShowGenres } from './api'
|
||||
|
||||
/**
|
||||
* Жанры шоу: бейджи в шапке карточки + диалог правки. Основной жанр отмечается отдельно —
|
||||
* он показывается в списке шоу и участвует в отборе контента наравне с остальными.
|
||||
*/
|
||||
export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [primary, setPrimary] = useState<string | null>(null)
|
||||
|
||||
const { data: genres } = useQuery({
|
||||
queryKey: ['admin', 'genres'],
|
||||
queryFn: listGenres,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => setShowGenres(show.id, selected, primary),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
const openDialog = () => {
|
||||
setSelected(show.genres.map((g) => g.id))
|
||||
setPrimary(show.genres.find((g) => g.isPrimary)?.id ?? null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
// Снятый жанр не может остаться основным — иначе сервер молча выберет другой.
|
||||
if (!next.includes(id) && primary === id) setPrimary(next[0] ?? null)
|
||||
if (next.includes(id) && primary === null) setPrimary(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{show.genres.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{t('admin.shows.genresEmpty')}</span>
|
||||
) : (
|
||||
show.genres.map((genre) => (
|
||||
<Badge key={genre.id} variant={genre.isPrimary ? 'default' : 'muted'}>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={openDialog}>
|
||||
<Tag className="h-4 w-4" /> {t('admin.shows.genresEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.shows.genresEdit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.shows.genresHint')}</p>
|
||||
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
|
||||
{(genres ?? []).map((genre) => {
|
||||
const checked = selected.includes(genre.id)
|
||||
return (
|
||||
<div
|
||||
key={genre.id}
|
||||
className="flex items-center justify-between gap-3 rounded px-2 py-1 hover:bg-muted/40"
|
||||
>
|
||||
<label className="flex flex-1 items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(genre.id)} />
|
||||
{genre.name}
|
||||
</label>
|
||||
{checked && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="primaryGenre"
|
||||
checked={primary === genre.id}
|
||||
onChange={() => setPrimary(genre.id)}
|
||||
/>
|
||||
{t('admin.shows.genrePrimary')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ShowAudience, ShowKind } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -11,6 +11,7 @@ import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { createShow, deleteShow, listShows } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -30,7 +31,13 @@ export function ShowsPanel() {
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
|
||||
const [genreFilter, setGenreFilter] = useState('all')
|
||||
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'shows', { genreId: genreFilter }],
|
||||
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
|
||||
})
|
||||
|
||||
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
|
||||
const filtered = useMemo(() => {
|
||||
@@ -46,6 +53,7 @@ export function ShowsPanel() {
|
||||
name: (s) => s.name.toLowerCase(),
|
||||
kind: (s) => s.kind,
|
||||
audience: (s) => s.audience,
|
||||
genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
|
||||
seasons: (s) => s.seasonCount,
|
||||
episodes: (s) => s.episodeCount,
|
||||
})
|
||||
@@ -104,9 +112,11 @@ export function ShowsPanel() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
|
||||
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
|
||||
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -118,15 +128,36 @@ export function ShowsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setQuery(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setQuery(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={genreFilter}
|
||||
onValueChange={(v) => {
|
||||
setPage(1)
|
||||
setGenreFilter(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('admin.shows.allGenres')}</SelectItem>
|
||||
{(genres ?? []).map((genre) => (
|
||||
<SelectItem key={genre.id} value={genre.id}>
|
||||
{genre.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
@@ -150,6 +181,12 @@ export function ShowsPanel() {
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.genre')}
|
||||
sortKey="genre"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.seasons')}
|
||||
sortKey="seasons"
|
||||
@@ -168,7 +205,7 @@ export function ShowsPanel() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={7}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -190,6 +227,7 @@ export function ShowsPanel() {
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
|
||||
@@ -9,8 +9,9 @@ import type {
|
||||
ShowSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listShows() {
|
||||
return apiRequest<ShowSummaryDto[]>('/admin/shows')
|
||||
export function listShows(genreId?: string) {
|
||||
const query = genreId ? `?${new URLSearchParams({ genreId }).toString()}` : ''
|
||||
return apiRequest<ShowSummaryDto[]>(`/admin/shows${query}`)
|
||||
}
|
||||
|
||||
export function getShow(id: string) {
|
||||
@@ -31,6 +32,14 @@ export function setShowAudience(id: string, audience: ShowAudience) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
|
||||
}
|
||||
|
||||
/** Полностью заменяет набор жанров шоу; основной — primaryGenreId (иначе первый в списке). */
|
||||
export function setShowGenres(id: string, genreIds: string[], primaryGenreId: string | null) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/genres`, {
|
||||
method: 'PUT',
|
||||
body: { genreIds, primaryGenreId },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameShow(id: string, name: string) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ 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 AdminCollectionsRouteImport } from './routes/admin/collections'
|
||||
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
|
||||
import { Route as AdminGenresRouteImport } from './routes/admin/genres'
|
||||
import { Route as AdminGroupsRouteImport } from './routes/admin/groups'
|
||||
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
|
||||
import { Route as AdminMediaRouteImport } from './routes/admin/media'
|
||||
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
|
||||
@@ -26,6 +29,10 @@ import { Route as AdminShowsRouteImport } from './routes/admin/shows'
|
||||
import { Route as AdminUsersRouteImport } from './routes/admin/users'
|
||||
import { Route as AdminChannelsIndexRouteImport } from './routes/admin/channels.index'
|
||||
import { Route as AdminChannelsChannelIdRouteImport } from './routes/admin/channels.$channelId'
|
||||
import { Route as AdminCollectionsIndexRouteImport } from './routes/admin/collections.index'
|
||||
import { Route as AdminCollectionsCollectionIdRouteImport } from './routes/admin/collections.$collectionId'
|
||||
import { Route as AdminGroupsIndexRouteImport } from './routes/admin/groups.index'
|
||||
import { Route as AdminGroupsGroupIdRouteImport } from './routes/admin/groups.$groupId'
|
||||
import { Route as AdminShowsIndexRouteImport } from './routes/admin/shows.index'
|
||||
import { Route as AdminShowsShowIdRouteImport } from './routes/admin/shows.$showId'
|
||||
|
||||
@@ -69,11 +76,26 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
|
||||
path: '/channels',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminCollectionsRoute = AdminCollectionsRouteImport.update({
|
||||
id: '/collections',
|
||||
path: '/collections',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminGalleryRoute = AdminGalleryRouteImport.update({
|
||||
id: '/gallery',
|
||||
path: '/gallery',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminGenresRoute = AdminGenresRouteImport.update({
|
||||
id: '/genres',
|
||||
path: '/genres',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminGroupsRoute = AdminGroupsRouteImport.update({
|
||||
id: '/groups',
|
||||
path: '/groups',
|
||||
getParentRoute: () => AdminRoute,
|
||||
} as any)
|
||||
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
|
||||
id: '/maintenance',
|
||||
path: '/maintenance',
|
||||
@@ -114,6 +136,27 @@ const AdminChannelsChannelIdRoute = AdminChannelsChannelIdRouteImport.update({
|
||||
path: '/$channelId',
|
||||
getParentRoute: () => AdminChannelsRoute,
|
||||
} as any)
|
||||
const AdminCollectionsIndexRoute = AdminCollectionsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AdminCollectionsRoute,
|
||||
} as any)
|
||||
const AdminCollectionsCollectionIdRoute =
|
||||
AdminCollectionsCollectionIdRouteImport.update({
|
||||
id: '/$collectionId',
|
||||
path: '/$collectionId',
|
||||
getParentRoute: () => AdminCollectionsRoute,
|
||||
} as any)
|
||||
const AdminGroupsIndexRoute = AdminGroupsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AdminGroupsRoute,
|
||||
} as any)
|
||||
const AdminGroupsGroupIdRoute = AdminGroupsGroupIdRouteImport.update({
|
||||
id: '/$groupId',
|
||||
path: '/$groupId',
|
||||
getParentRoute: () => AdminGroupsRoute,
|
||||
} as any)
|
||||
const AdminShowsIndexRoute = AdminShowsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -133,7 +176,10 @@ export interface FileRoutesByFullPath {
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/collections': typeof AdminCollectionsRouteWithChildren
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -142,8 +188,12 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/collections/$collectionId': typeof AdminCollectionsCollectionIdRoute
|
||||
'/admin/groups/$groupId': typeof AdminGroupsGroupIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
'/admin/channels/': typeof AdminChannelsIndexRoute
|
||||
'/admin/collections/': typeof AdminCollectionsIndexRoute
|
||||
'/admin/groups/': typeof AdminGroupsIndexRoute
|
||||
'/admin/shows/': typeof AdminShowsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -153,6 +203,7 @@ export interface FileRoutesByTo {
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -160,8 +211,12 @@ export interface FileRoutesByTo {
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/collections/$collectionId': typeof AdminCollectionsCollectionIdRoute
|
||||
'/admin/groups/$groupId': typeof AdminGroupsGroupIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
'/admin/channels': typeof AdminChannelsIndexRoute
|
||||
'/admin/collections': typeof AdminCollectionsIndexRoute
|
||||
'/admin/groups': typeof AdminGroupsIndexRoute
|
||||
'/admin/shows': typeof AdminShowsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -173,7 +228,10 @@ export interface FileRoutesById {
|
||||
'/register': typeof RegisterRoute
|
||||
'/settings': typeof SettingsRoute
|
||||
'/admin/channels': typeof AdminChannelsRouteWithChildren
|
||||
'/admin/collections': typeof AdminCollectionsRouteWithChildren
|
||||
'/admin/gallery': typeof AdminGalleryRoute
|
||||
'/admin/genres': typeof AdminGenresRoute
|
||||
'/admin/groups': typeof AdminGroupsRouteWithChildren
|
||||
'/admin/maintenance': typeof AdminMaintenanceRoute
|
||||
'/admin/media': typeof AdminMediaRoute
|
||||
'/admin/roles': typeof AdminRolesRoute
|
||||
@@ -182,8 +240,12 @@ export interface FileRoutesById {
|
||||
'/admin/users': typeof AdminUsersRoute
|
||||
'/admin/': typeof AdminIndexRoute
|
||||
'/admin/channels/$channelId': typeof AdminChannelsChannelIdRoute
|
||||
'/admin/collections/$collectionId': typeof AdminCollectionsCollectionIdRoute
|
||||
'/admin/groups/$groupId': typeof AdminGroupsGroupIdRoute
|
||||
'/admin/shows/$showId': typeof AdminShowsShowIdRoute
|
||||
'/admin/channels/': typeof AdminChannelsIndexRoute
|
||||
'/admin/collections/': typeof AdminCollectionsIndexRoute
|
||||
'/admin/groups/': typeof AdminGroupsIndexRoute
|
||||
'/admin/shows/': typeof AdminShowsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -196,7 +258,10 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/collections'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -205,8 +270,12 @@ export interface FileRouteTypes {
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/collections/$collectionId'
|
||||
| '/admin/groups/$groupId'
|
||||
| '/admin/shows/$showId'
|
||||
| '/admin/channels/'
|
||||
| '/admin/collections/'
|
||||
| '/admin/groups/'
|
||||
| '/admin/shows/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -216,6 +285,7 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -223,8 +293,12 @@ export interface FileRouteTypes {
|
||||
| '/admin/users'
|
||||
| '/admin'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/collections/$collectionId'
|
||||
| '/admin/groups/$groupId'
|
||||
| '/admin/shows/$showId'
|
||||
| '/admin/channels'
|
||||
| '/admin/collections'
|
||||
| '/admin/groups'
|
||||
| '/admin/shows'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -235,7 +309,10 @@ export interface FileRouteTypes {
|
||||
| '/register'
|
||||
| '/settings'
|
||||
| '/admin/channels'
|
||||
| '/admin/collections'
|
||||
| '/admin/gallery'
|
||||
| '/admin/genres'
|
||||
| '/admin/groups'
|
||||
| '/admin/maintenance'
|
||||
| '/admin/media'
|
||||
| '/admin/roles'
|
||||
@@ -244,8 +321,12 @@ export interface FileRouteTypes {
|
||||
| '/admin/users'
|
||||
| '/admin/'
|
||||
| '/admin/channels/$channelId'
|
||||
| '/admin/collections/$collectionId'
|
||||
| '/admin/groups/$groupId'
|
||||
| '/admin/shows/$showId'
|
||||
| '/admin/channels/'
|
||||
| '/admin/collections/'
|
||||
| '/admin/groups/'
|
||||
| '/admin/shows/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -316,6 +397,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminChannelsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/collections': {
|
||||
id: '/admin/collections'
|
||||
path: '/collections'
|
||||
fullPath: '/admin/collections'
|
||||
preLoaderRoute: typeof AdminCollectionsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/gallery': {
|
||||
id: '/admin/gallery'
|
||||
path: '/gallery'
|
||||
@@ -323,6 +411,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminGalleryRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/genres': {
|
||||
id: '/admin/genres'
|
||||
path: '/genres'
|
||||
fullPath: '/admin/genres'
|
||||
preLoaderRoute: typeof AdminGenresRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/groups': {
|
||||
id: '/admin/groups'
|
||||
path: '/groups'
|
||||
fullPath: '/admin/groups'
|
||||
preLoaderRoute: typeof AdminGroupsRouteImport
|
||||
parentRoute: typeof AdminRoute
|
||||
}
|
||||
'/admin/maintenance': {
|
||||
id: '/admin/maintenance'
|
||||
path: '/maintenance'
|
||||
@@ -379,6 +481,34 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminChannelsChannelIdRouteImport
|
||||
parentRoute: typeof AdminChannelsRoute
|
||||
}
|
||||
'/admin/collections/': {
|
||||
id: '/admin/collections/'
|
||||
path: '/'
|
||||
fullPath: '/admin/collections/'
|
||||
preLoaderRoute: typeof AdminCollectionsIndexRouteImport
|
||||
parentRoute: typeof AdminCollectionsRoute
|
||||
}
|
||||
'/admin/collections/$collectionId': {
|
||||
id: '/admin/collections/$collectionId'
|
||||
path: '/$collectionId'
|
||||
fullPath: '/admin/collections/$collectionId'
|
||||
preLoaderRoute: typeof AdminCollectionsCollectionIdRouteImport
|
||||
parentRoute: typeof AdminCollectionsRoute
|
||||
}
|
||||
'/admin/groups/': {
|
||||
id: '/admin/groups/'
|
||||
path: '/'
|
||||
fullPath: '/admin/groups/'
|
||||
preLoaderRoute: typeof AdminGroupsIndexRouteImport
|
||||
parentRoute: typeof AdminGroupsRoute
|
||||
}
|
||||
'/admin/groups/$groupId': {
|
||||
id: '/admin/groups/$groupId'
|
||||
path: '/$groupId'
|
||||
fullPath: '/admin/groups/$groupId'
|
||||
preLoaderRoute: typeof AdminGroupsGroupIdRouteImport
|
||||
parentRoute: typeof AdminGroupsRoute
|
||||
}
|
||||
'/admin/shows/': {
|
||||
id: '/admin/shows/'
|
||||
path: '/'
|
||||
@@ -410,6 +540,33 @@ const AdminChannelsRouteWithChildren = AdminChannelsRoute._addFileChildren(
|
||||
AdminChannelsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminCollectionsRouteChildren {
|
||||
AdminCollectionsCollectionIdRoute: typeof AdminCollectionsCollectionIdRoute
|
||||
AdminCollectionsIndexRoute: typeof AdminCollectionsIndexRoute
|
||||
}
|
||||
|
||||
const AdminCollectionsRouteChildren: AdminCollectionsRouteChildren = {
|
||||
AdminCollectionsCollectionIdRoute: AdminCollectionsCollectionIdRoute,
|
||||
AdminCollectionsIndexRoute: AdminCollectionsIndexRoute,
|
||||
}
|
||||
|
||||
const AdminCollectionsRouteWithChildren =
|
||||
AdminCollectionsRoute._addFileChildren(AdminCollectionsRouteChildren)
|
||||
|
||||
interface AdminGroupsRouteChildren {
|
||||
AdminGroupsGroupIdRoute: typeof AdminGroupsGroupIdRoute
|
||||
AdminGroupsIndexRoute: typeof AdminGroupsIndexRoute
|
||||
}
|
||||
|
||||
const AdminGroupsRouteChildren: AdminGroupsRouteChildren = {
|
||||
AdminGroupsGroupIdRoute: AdminGroupsGroupIdRoute,
|
||||
AdminGroupsIndexRoute: AdminGroupsIndexRoute,
|
||||
}
|
||||
|
||||
const AdminGroupsRouteWithChildren = AdminGroupsRoute._addFileChildren(
|
||||
AdminGroupsRouteChildren,
|
||||
)
|
||||
|
||||
interface AdminShowsRouteChildren {
|
||||
AdminShowsShowIdRoute: typeof AdminShowsShowIdRoute
|
||||
AdminShowsIndexRoute: typeof AdminShowsIndexRoute
|
||||
@@ -426,7 +583,10 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
|
||||
|
||||
interface AdminRouteChildren {
|
||||
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
|
||||
AdminCollectionsRoute: typeof AdminCollectionsRouteWithChildren
|
||||
AdminGalleryRoute: typeof AdminGalleryRoute
|
||||
AdminGenresRoute: typeof AdminGenresRoute
|
||||
AdminGroupsRoute: typeof AdminGroupsRouteWithChildren
|
||||
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
|
||||
AdminMediaRoute: typeof AdminMediaRoute
|
||||
AdminRolesRoute: typeof AdminRolesRoute
|
||||
@@ -438,7 +598,10 @@ interface AdminRouteChildren {
|
||||
|
||||
const AdminRouteChildren: AdminRouteChildren = {
|
||||
AdminChannelsRoute: AdminChannelsRouteWithChildren,
|
||||
AdminCollectionsRoute: AdminCollectionsRouteWithChildren,
|
||||
AdminGalleryRoute: AdminGalleryRoute,
|
||||
AdminGenresRoute: AdminGenresRoute,
|
||||
AdminGroupsRoute: AdminGroupsRouteWithChildren,
|
||||
AdminMaintenanceRoute: AdminMaintenanceRoute,
|
||||
AdminMediaRoute: AdminMediaRoute,
|
||||
AdminRolesRoute: AdminRolesRoute,
|
||||
|
||||
@@ -29,6 +29,27 @@ function AdminLayout() {
|
||||
>
|
||||
{t('admin.shows.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/groups"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.groups.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/collections"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.collections.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/genres"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
|
||||
>
|
||||
{t('admin.genres.title')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/channels"
|
||||
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CollectionDetail } from '@/features/admin/collections/CollectionDetail'
|
||||
|
||||
export const Route = createFileRoute('/admin/collections/$collectionId')({
|
||||
component: CollectionDetailRoute,
|
||||
})
|
||||
|
||||
function CollectionDetailRoute() {
|
||||
const { collectionId } = Route.useParams()
|
||||
return <CollectionDetail collectionId={collectionId} />
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { CollectionsPanel } from '@/features/admin/collections/CollectionsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/collections/')({ component: CollectionsPanel })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
// Лейаут для /admin/collections/*: список — в index-роуте, деталь — в collections.$collectionId.
|
||||
export const Route = createFileRoute('/admin/collections')({ component: () => <Outlet /> })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { GenresPanel } from '@/features/admin/genres/GenresPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/genres')({ component: GenresPanel })
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { GroupDetail } from '@/features/admin/groups/GroupDetail'
|
||||
|
||||
export const Route = createFileRoute('/admin/groups/$groupId')({ component: GroupDetailRoute })
|
||||
|
||||
function GroupDetailRoute() {
|
||||
const { groupId } = Route.useParams()
|
||||
return <GroupDetail groupId={groupId} />
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { GroupsPanel } from '@/features/admin/groups/GroupsPanel'
|
||||
|
||||
export const Route = createFileRoute('/admin/groups/')({ component: GroupsPanel })
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
// Лейаут для /admin/groups/*: список — в index-роуте, редактор — в groups.$groupId.
|
||||
export const Route = createFileRoute('/admin/groups')({ component: () => <Outlet /> })
|
||||
+477
-294
@@ -1,294 +1,477 @@
|
||||
export type ApiError = {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: CurrentUser
|
||||
}
|
||||
|
||||
export type RegistrationStatus = { enabled: boolean }
|
||||
|
||||
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
name: string
|
||||
isSystem: boolean
|
||||
}
|
||||
|
||||
export type UserSummaryDto = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
isBlocked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PagedList<T> = {
|
||||
items: T[]
|
||||
total: number
|
||||
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
|
||||
width: number | null
|
||||
height: number | null
|
||||
errorMessage: string | null
|
||||
processingSeconds: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MediaStatsDto = {
|
||||
queued: number
|
||||
processing: number
|
||||
averageProcessingSeconds: number | null
|
||||
}
|
||||
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
|
||||
/** Категория аудитории: обычное / детское / взрослое. */
|
||||
export type ShowAudience = 'General' | 'Kids' | 'Adult'
|
||||
|
||||
export type ShowSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
episodeCount: number
|
||||
seasonCount: number
|
||||
year: number | null
|
||||
hasPoster: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MetadataCandidate = {
|
||||
externalId: string
|
||||
title: string
|
||||
year: number | null
|
||||
overview: string | null
|
||||
posterUrl: string | null
|
||||
}
|
||||
|
||||
export type SeasonGapDto = {
|
||||
season: number
|
||||
expected: number | null
|
||||
loaded: number
|
||||
missing: number[]
|
||||
}
|
||||
|
||||
export type MissingEpisodesReport = {
|
||||
seasons: SeasonGapDto[]
|
||||
}
|
||||
|
||||
export type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
position: number
|
||||
assetName: string | null
|
||||
assetStatus: MediaAssetStatus | null
|
||||
durationSeconds: number | null
|
||||
season: number | null
|
||||
episode: number | null
|
||||
title: string | null
|
||||
overview: string | null
|
||||
stillImageId: string | null
|
||||
}
|
||||
|
||||
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
|
||||
|
||||
export type ImageDto = {
|
||||
id: string
|
||||
category: ImageCategory
|
||||
originalFileName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ShowDto = {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
description: string | null
|
||||
metadataProvider: string | null
|
||||
metadataExternalId: string | null
|
||||
year: number | null
|
||||
posterImageId: string | null
|
||||
episodes: EpisodeDto[]
|
||||
}
|
||||
|
||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||
export type BlockMode = 'Count' | 'Duration'
|
||||
export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
|
||||
export type OverrideMode = 'Exclusive' | 'Boost'
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||
export type BumperSettings = {
|
||||
font: BumperFont
|
||||
minIntervalMinutes: number
|
||||
selection: BumperSelection
|
||||
/** Вероятность заставки на смене шоу (0..1). */
|
||||
showChangeChance: number
|
||||
/** Вероятность заставки между блоками одного шоу (0..1). */
|
||||
episodeChangeChance: number
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
export type BumperTextVariantDto = {
|
||||
id: string
|
||||
position: number
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
|
||||
weight: number
|
||||
}
|
||||
|
||||
export type BumperTemplateDto = {
|
||||
id: string
|
||||
position: number
|
||||
isDefault: boolean
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
backgroundImageId: string | null
|
||||
hasAudio: boolean
|
||||
audioDurationSeconds: number | null
|
||||
variants: BumperTextVariantDto[]
|
||||
}
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */
|
||||
export type HourWindow = { startHour: number; endHour: number }
|
||||
|
||||
export type ChannelShowDto = {
|
||||
id: string
|
||||
showId: string
|
||||
showName: string
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
}
|
||||
|
||||
export type ChannelAdDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
assetName: string | null
|
||||
position: number
|
||||
}
|
||||
|
||||
export type OverrideShowDto = { showId: string; showName: string; weight: number }
|
||||
|
||||
export type OverrideRecurrence = 'OneTime' | 'Weekly'
|
||||
|
||||
export type ProgrammingOverrideDto = {
|
||||
id: string
|
||||
mode: OverrideMode
|
||||
recurrence: OverrideRecurrence
|
||||
startsAtUtc: string | null
|
||||
endsAtUtc: string | null
|
||||
/** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */
|
||||
dayOfWeek: number | null
|
||||
startMinute: number | null
|
||||
endMinute: number | null
|
||||
shows: OverrideShowDto[]
|
||||
}
|
||||
|
||||
export type ChannelDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
bumperTemplates: BumperTemplateDto[]
|
||||
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
|
||||
seasonEpisode: string | null
|
||||
/** Для заставок: имя подблока и его текст — для метки в расписании. */
|
||||
bumperName: string | null
|
||||
bumperText: string | null
|
||||
}
|
||||
|
||||
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
||||
export type PublicChannelDto = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
currentShowId: string | null
|
||||
currentShowName: string | null
|
||||
currentShowPosterImageId: string | null
|
||||
}
|
||||
|
||||
export type PublicEpgEntryDto = {
|
||||
kind: ScheduleEntryKind
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showId: string | null
|
||||
showName: string | null
|
||||
showPosterImageId: string | null
|
||||
episodeId: string | null
|
||||
episodeTitle: string | null
|
||||
episodeOverview: string | null
|
||||
episodeStillImageId: string | null
|
||||
}
|
||||
export type ApiError = {
|
||||
title: string
|
||||
detail: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type CurrentUser = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export type AuthResponse = {
|
||||
accessToken: string
|
||||
expiresAt: string
|
||||
user: CurrentUser
|
||||
}
|
||||
|
||||
export type RegistrationStatus = { enabled: boolean }
|
||||
|
||||
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
|
||||
|
||||
export type RoleDto = {
|
||||
id: string
|
||||
name: string
|
||||
isSystem: boolean
|
||||
}
|
||||
|
||||
export type UserSummaryDto = {
|
||||
id: string
|
||||
userName: string
|
||||
role: string
|
||||
isBlocked: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PagedList<T> = {
|
||||
items: T[]
|
||||
total: number
|
||||
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
|
||||
width: number | null
|
||||
height: number | null
|
||||
errorMessage: string | null
|
||||
processingSeconds: number | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type MediaStatsDto = {
|
||||
queued: number
|
||||
processing: number
|
||||
averageProcessingSeconds: number | null
|
||||
}
|
||||
|
||||
// ── Библиотека (жанры) ─────────────────────────────────────────────────────
|
||||
export type GenreDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
sortOrder: number
|
||||
isSystem: boolean
|
||||
/** Варианты написания для сопоставления с метаданными провайдеров. */
|
||||
aliases: string[]
|
||||
/** Сколько шоу используют жанр — при ненулевом удаление заблокировано. */
|
||||
showCount: number
|
||||
}
|
||||
|
||||
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
|
||||
export type ShowKind = 'Series' | 'Single'
|
||||
|
||||
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
|
||||
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
|
||||
|
||||
/** Тот же порядок для селектов и списков. */
|
||||
export const SHOW_AUDIENCES: ShowAudience[] = ['Kids', 'Family', 'Teen', 'General', 'Adult']
|
||||
|
||||
export type ShowSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
episodeCount: number
|
||||
seasonCount: number
|
||||
year: number | null
|
||||
hasPoster: boolean
|
||||
createdAt: string
|
||||
/** Основной жанр — в списке показывается только он. */
|
||||
primaryGenre: string | null
|
||||
}
|
||||
|
||||
export type ShowGenreDto = {
|
||||
id: string
|
||||
name: string
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
// ── Библиотека (коллекции) ─────────────────────────────────────────────────
|
||||
export type CollectionSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
posterImageId: string | null
|
||||
/** Сколько частей в коллекции. */
|
||||
itemCount: number
|
||||
/** Сколько единиц воспроизведения суммарно — у сериала внутри коллекции их больше одной. */
|
||||
unitCount: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type CollectionItemDto = {
|
||||
showId: string
|
||||
position: number
|
||||
showName: string
|
||||
showKind: ShowKind
|
||||
showAudience: ShowAudience
|
||||
episodeCount: number
|
||||
year: number | null
|
||||
posterImageId: string | null
|
||||
}
|
||||
|
||||
export type CollectionDto = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
posterImageId: string | null
|
||||
createdAt: string
|
||||
items: CollectionItemDto[]
|
||||
}
|
||||
|
||||
/** Коллекция, в которую входит шоу — для блока на экране шоу. */
|
||||
export type ShowCollectionRefDto = {
|
||||
id: string
|
||||
name: string
|
||||
position: number
|
||||
}
|
||||
|
||||
// ── Группы контента (планировщик) ──────────────────────────────────────────
|
||||
export type GroupElementKind = 'Show' | 'Collection'
|
||||
|
||||
/** Правило быстрого набора состава. Пустые поля не ограничивают. */
|
||||
export type GroupFilter = {
|
||||
elementKinds?: GroupElementKind[] | null
|
||||
showKinds?: ShowKind[] | null
|
||||
genreIds?: string[] | null
|
||||
maxAudience?: ShowAudience | null
|
||||
yearMin?: number | null
|
||||
yearMax?: number | null
|
||||
unitMinutesMin?: number | null
|
||||
unitMinutesMax?: number | null
|
||||
}
|
||||
|
||||
export type GroupSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
itemCount: number
|
||||
unitCount: number
|
||||
totalDurationSeconds: number
|
||||
hasFilter: boolean
|
||||
statsComputedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type GroupItemDto = {
|
||||
id: string
|
||||
elementKind: GroupElementKind
|
||||
elementId: string
|
||||
elementName: string
|
||||
weight: number
|
||||
position: number
|
||||
unitCount: number
|
||||
showKind: ShowKind | null
|
||||
audience: ShowAudience | null
|
||||
year: number | null
|
||||
posterImageId: string | null
|
||||
}
|
||||
|
||||
export type GroupDto = {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
filter: GroupFilter | null
|
||||
itemCount: number
|
||||
unitCount: number
|
||||
totalDurationSeconds: number
|
||||
statsComputedAt: string | null
|
||||
items: GroupItemDto[]
|
||||
}
|
||||
|
||||
/** Кандидат, найденный правилом набора. */
|
||||
export type GroupCandidateDto = {
|
||||
elementKind: GroupElementKind
|
||||
elementId: string
|
||||
elementName: string
|
||||
unitCount: number
|
||||
showKind: ShowKind | null
|
||||
audience: ShowAudience | null
|
||||
year: number | null
|
||||
posterImageId: string | null
|
||||
alreadyInGroup: boolean
|
||||
}
|
||||
|
||||
export type MetadataCandidate = {
|
||||
externalId: string
|
||||
title: string
|
||||
year: number | null
|
||||
overview: string | null
|
||||
posterUrl: string | null
|
||||
}
|
||||
|
||||
export type SeasonGapDto = {
|
||||
season: number
|
||||
expected: number | null
|
||||
loaded: number
|
||||
missing: number[]
|
||||
}
|
||||
|
||||
export type MissingEpisodesReport = {
|
||||
seasons: SeasonGapDto[]
|
||||
}
|
||||
|
||||
export type EpisodeDto = {
|
||||
id: string
|
||||
mediaAssetId: string
|
||||
position: number
|
||||
assetName: string | null
|
||||
assetStatus: MediaAssetStatus | null
|
||||
durationSeconds: number | null
|
||||
season: number | null
|
||||
episode: number | null
|
||||
title: string | null
|
||||
overview: string | null
|
||||
stillImageId: string | null
|
||||
}
|
||||
|
||||
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
|
||||
|
||||
export type ImageDto = {
|
||||
id: string
|
||||
category: ImageCategory
|
||||
originalFileName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ShowDto = {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string | null
|
||||
kind: ShowKind
|
||||
audience: ShowAudience
|
||||
description: string | null
|
||||
metadataProvider: string | null
|
||||
metadataExternalId: string | null
|
||||
year: number | null
|
||||
posterImageId: string | null
|
||||
episodes: EpisodeDto[]
|
||||
genres: ShowGenreDto[]
|
||||
collections: ShowCollectionRefDto[]
|
||||
}
|
||||
|
||||
// ── Каналы ────────────────────────────────────────────────────────────────
|
||||
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
|
||||
export type BumperFont = 'Sans' | 'Serif'
|
||||
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
|
||||
export type BumperTextKind = 'NowNext' | 'Free'
|
||||
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
|
||||
|
||||
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
|
||||
export type BumperSettings = {
|
||||
font: BumperFont
|
||||
minIntervalMinutes: number
|
||||
selection: BumperSelection
|
||||
/** Вероятность заставки на смене шоу (0..1). */
|
||||
showChangeChance: number
|
||||
/** Вероятность заставки между блоками одного шоу (0..1). */
|
||||
episodeChangeChance: number
|
||||
}
|
||||
|
||||
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
|
||||
export type BumperTextVariantDto = {
|
||||
id: string
|
||||
position: number
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
|
||||
weight: number
|
||||
}
|
||||
|
||||
export type BumperTemplateDto = {
|
||||
id: string
|
||||
position: number
|
||||
isDefault: boolean
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
backgroundImageId: string | null
|
||||
hasAudio: boolean
|
||||
audioDurationSeconds: number | null
|
||||
variants: BumperTextVariantDto[]
|
||||
}
|
||||
|
||||
export type ChannelSummaryDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
export type ChannelDto = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
isEnabled: boolean
|
||||
/** Номер канала для переключения по номерам (null — не задан). */
|
||||
number: number | null
|
||||
/** Смещение времени канала от UTC, минуты (180 — московское). */
|
||||
utcOffsetMinutes: number
|
||||
/** Начало вещательных суток в времени канала («06:00:00»). */
|
||||
dayStartTime: string
|
||||
templateId: string | null
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
bumperTemplates: BumperTemplateDto[]
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
|
||||
export type Daypart = 'Morning' | 'Day' | 'Prime' | 'Night'
|
||||
export type SlotKind = 'Content' | 'Repeat' | 'SignOff'
|
||||
export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot'
|
||||
export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits'
|
||||
export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed'
|
||||
export type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'
|
||||
|
||||
export type SlotStrategy = {
|
||||
type: SlotStrategyType
|
||||
restartOnEnd: boolean
|
||||
cooldownDays: number
|
||||
fallback: CooldownFallback
|
||||
fixedElementId?: string | null
|
||||
}
|
||||
|
||||
/** Что повторяет слот-повтор: точка в уже записанной ленте того же канала. */
|
||||
export type RepeatSource = { daysAgo: number; time: string; durationMinutes: number }
|
||||
|
||||
export type SlotDto = {
|
||||
id: string
|
||||
layerId: string
|
||||
/** День недели вещательных суток (0=Вс..6=Сб) или null — каждый день. */
|
||||
weekday: number | null
|
||||
targetStart: string
|
||||
targetDurationMinutes: number
|
||||
title: string
|
||||
daypart: Daypart
|
||||
slotKind: SlotKind
|
||||
groupId: string | null
|
||||
groupName: string | null
|
||||
strategy: SlotStrategy | null
|
||||
repeatSource: RepeatSource | null
|
||||
blockMode: SlotBlockMode
|
||||
blockValue: number
|
||||
overflowPolicy: OverflowPolicy
|
||||
isAnchor: boolean
|
||||
maxDriftMinutes: number
|
||||
/** Округление старта до кратного N минут (5/10/15/30) или null. */
|
||||
snapToMinutes: number | null
|
||||
}
|
||||
|
||||
export type DateRange = { from: string; to: string }
|
||||
export type AnnualRange = { fromMonth: number; fromDay: number; toMonth: number; toDay: number }
|
||||
|
||||
/** Когда действует слой. Пустая применимость — слой действует всегда. */
|
||||
export type LayerApplicability = {
|
||||
weekdays?: number[] | null
|
||||
dateRanges?: DateRange[] | null
|
||||
annualRanges?: AnnualRange[] | null
|
||||
specificDates?: string[] | null
|
||||
}
|
||||
|
||||
export type GridLayerDto = {
|
||||
id: string
|
||||
name: string
|
||||
priority: number
|
||||
isEnabled: boolean
|
||||
isBackground: boolean
|
||||
applicability: LayerApplicability | null
|
||||
slots: SlotDto[]
|
||||
}
|
||||
|
||||
export type ScheduleTemplateDto = {
|
||||
id: string
|
||||
channelId: string
|
||||
name: string
|
||||
fallbackGroupId: string | null
|
||||
revision: number
|
||||
appliedRevision: number
|
||||
/** Есть ли правки правил, не применённые к эфиру. */
|
||||
hasPendingChanges: boolean
|
||||
utcOffsetMinutes: number
|
||||
dayStartTime: string
|
||||
layers: GridLayerDto[]
|
||||
}
|
||||
|
||||
export type PlanningWarningKind =
|
||||
| 'SlotEmpty'
|
||||
| 'DriftExceeded'
|
||||
| 'CooldownExhausted'
|
||||
| 'RepeatSourceEmpty'
|
||||
| 'FallbackEmpty'
|
||||
|
||||
export type PlanningWarningDto = {
|
||||
kind: PlanningWarningKind
|
||||
slotId: string | null
|
||||
details: string
|
||||
}
|
||||
|
||||
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
|
||||
|
||||
export type ScheduleEntryDto = {
|
||||
id: string
|
||||
kind: ScheduleEntryKind
|
||||
mediaAssetId: string
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showId: string | null
|
||||
showName: string | null
|
||||
episodeIndex: number | null
|
||||
seasonEpisode: string | null
|
||||
/** Для заставок: имя подблока и его текст — для метки в расписании. */
|
||||
bumperName: string | null
|
||||
bumperText: string | null
|
||||
}
|
||||
|
||||
// ── Публичный эфир ─────────────────────────────────────────────────────────
|
||||
export type PublicChannelDto = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
currentShowId: string | null
|
||||
currentShowName: string | null
|
||||
currentShowPosterImageId: string | null
|
||||
}
|
||||
|
||||
export type PublicEpgEntryDto = {
|
||||
kind: ScheduleEntryKind
|
||||
startsAtUtc: string
|
||||
endsAtUtc: string
|
||||
showId: string | null
|
||||
showName: string | null
|
||||
showPosterImageId: string | null
|
||||
episodeId: string | null
|
||||
episodeTitle: string | null
|
||||
episodeOverview: string | null
|
||||
episodeStillImageId: string | null
|
||||
}
|
||||
|
||||
+1040
-756
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user