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.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -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>
)
}
+244 -245
View File
@@ -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>
)
}
+21
View File
@@ -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>
)
}
+69
View File
@@ -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">
+11 -2
View File
@@ -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 } })
}