Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
This commit is contained in:
@@ -1,197 +1,186 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { deleteOverride, getChannel, getSchedule, regenerateSchedule, removeChannelAd } from './api'
|
||||
import { AddAdForm } from './components/AddAdForm'
|
||||
import { AddShowForm } from './components/AddShowForm'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { ChannelShowRow } from './components/ChannelShowRow'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { RemoveButton } from './components/fields'
|
||||
import { OverrideForm } from './components/OverrideForm'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { formatMinute, formatTime } from './lib/format'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
||||
})
|
||||
const { data: schedule } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'schedule'],
|
||||
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
|
||||
}
|
||||
const invalidateSchedule = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId, 'schedule'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: () => regenerateSchedule(channelId),
|
||||
onSuccess: () => {
|
||||
toast.success(t('admin.channels.regenerated'))
|
||||
void invalidateSchedule()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const availableShows = shows?.filter((s) => !channel.shows.some((cs) => cs.showId === s.id)) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/channels">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
<Badge variant="muted">{channel.slug}</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={regenerateMutation.isPending}
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
{t('admin.channels.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsCard
|
||||
channel={channel}
|
||||
readyAssets={ready?.items ?? []}
|
||||
onSaved={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
{/* Шоу канала */}
|
||||
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
|
||||
<AddShowForm
|
||||
channelId={channelId}
|
||||
options={availableShows.map((s) => ({ id: s.id, name: s.name }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 font-medium">{t('admin.channels.show')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.weight')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.block')}</th>
|
||||
<th className="py-2 font-medium">{t('admin.channels.on')}</th>
|
||||
<th className="py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{channel.shows.map((row) => (
|
||||
<ChannelShowRow
|
||||
key={row.id}
|
||||
channelId={channelId}
|
||||
row={row}
|
||||
onChanged={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{channel.shows.length === 0 && (
|
||||
<tr>
|
||||
<td className="py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('admin.channels.noShows')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Реклама */}
|
||||
<CollapsibleCard title={t('admin.channels.ads')} contentClassName="flex flex-col gap-3">
|
||||
<AddAdForm
|
||||
channelId={channelId}
|
||||
options={(ready?.items ?? [])
|
||||
.filter((a) => !channel.ads.some((ad) => ad.mediaAssetId === a.id))
|
||||
.map((a) => ({ id: a.id, name: a.originalFileName }))}
|
||||
onAdded={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
{ready?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.shows.candidatesTruncated')}</p>
|
||||
)}
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.ads.map((ad) => (
|
||||
<li key={ad.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>{ad.assetName ?? '—'}</span>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelAd(channelId, ad.id).then(invalidate).catch(onError)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.ads.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noAds')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Override'ы / марафоны */}
|
||||
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
|
||||
<OverrideForm
|
||||
channelId={channelId}
|
||||
options={channel.shows.map((cs) => ({ id: cs.showId, name: cs.showName }))}
|
||||
onCreated={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{channel.overrides.map((o) => (
|
||||
<li key={o.id} className="flex items-center justify-between py-2 text-sm">
|
||||
<span>
|
||||
<Badge variant="muted">{t(`admin.channels.modes.${o.mode}`)}</Badge>{' '}
|
||||
{o.recurrence === 'Weekly'
|
||||
? `${t(`admin.channels.weekdays.${o.dayOfWeek}`)} ${formatMinute(o.startMinute)}–${formatMinute(o.endMinute)}`
|
||||
: `${formatTime(o.startsAtUtc)} – ${formatTime(o.endsAtUtc)}`}{' '}
|
||||
· {o.shows.map((s) => s.showName).join(', ')}
|
||||
</span>
|
||||
<RemoveButton
|
||||
onClick={() => deleteOverride(channelId, o.id).then(invalidate).catch(onError)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{channel.overrides.length === 0 && (
|
||||
<li className="py-2 text-muted-foreground">{t('admin.channels.noOverrides')}</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleCard>
|
||||
|
||||
{/* Предпросмотр расписания */}
|
||||
<CollapsibleCard title={t('admin.channels.schedule')}>
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</CollapsibleCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, Plus, Send } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listAllMedia } from '@/features/admin/media/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GridLayerDto, SlotDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
applyChannelTemplate,
|
||||
createLayer,
|
||||
deleteLayer,
|
||||
getChannel,
|
||||
getChannelTemplate,
|
||||
getSchedule,
|
||||
} from './api'
|
||||
import { BumperCard } from './components/BumperCard'
|
||||
import { CollapsibleCard } from './components/CollapsibleCard'
|
||||
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
|
||||
import { SchedulePreview } from './components/SchedulePreview'
|
||||
import { SettingsCard } from './components/SettingsCard'
|
||||
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
|
||||
|
||||
export function ChannelDetail({ channelId }: { channelId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [draft, setDraft] = useState<SlotDraft | null>(null)
|
||||
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
|
||||
|
||||
const { data: channel, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId],
|
||||
queryFn: () => getChannel(channelId),
|
||||
})
|
||||
const { data: template } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'template'],
|
||||
queryFn: () => getChannelTemplate(channelId),
|
||||
})
|
||||
const { data: ready } = useQuery({
|
||||
queryKey: ['admin', 'media', 'ready', 'all'],
|
||||
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
|
||||
})
|
||||
const { data: schedule } = useQuery({
|
||||
queryKey: ['admin', 'channels', channelId, 'schedule'],
|
||||
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
|
||||
})
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'channels', channelId] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const applyMutation = useMutation({
|
||||
mutationFn: () => applyChannelTemplate(channelId),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.channels.applied', { count: result.added }))
|
||||
// Предупреждения показываем по одному: каждое указывает на конкретный слот.
|
||||
for (const warning of result.warnings)
|
||||
toast.error(`${t(`admin.channels.warnings.${warning.kind}`)}: ${warning.details}`)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addLayerMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const nextPriority = Math.max(0, ...(template?.layers.map((l) => l.priority) ?? [0])) + 10
|
||||
return createLayer(template!.id, {
|
||||
name: t('admin.channels.newLayerName'),
|
||||
priority: nextPriority,
|
||||
})
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const deleteLayerMutation = useMutation({
|
||||
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const layerForNewSlot =
|
||||
activeLayerId ?? template?.layers.find((l) => !l.isBackground)?.id ?? template?.layers[0]?.id
|
||||
|
||||
const openNewSlot = (weekday: number, startMinutes: number) => {
|
||||
if (!layerForNewSlot) return
|
||||
const hh = Math.floor(startMinutes / 60)
|
||||
.toString()
|
||||
.padStart(2, '0')
|
||||
const mm = (startMinutes % 60).toString().padStart(2, '0')
|
||||
setDraft({
|
||||
layerId: layerForNewSlot,
|
||||
slot: null,
|
||||
defaults: { weekday, targetStart: `${hh}:${mm}:00`, title: t('admin.channels.newSlot') },
|
||||
})
|
||||
}
|
||||
|
||||
const openSlot = (slot: SlotDto) => setDraft({ layerId: slot.layerId, slot })
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/channels">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.channels.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="crt-glow text-xl font-semibold">{channel.name}</h2>
|
||||
{channel.number !== null && <Badge variant="muted">№ {channel.number}</Badge>}
|
||||
{!channel.isEnabled && <Badge variant="muted">{t('admin.channels.disabled')}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правка правил эфира не двигает — применение отдельной кнопкой. */}
|
||||
{template?.hasPendingChanges && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-amber-500/50 bg-amber-500/10 px-4 py-2 text-sm">
|
||||
<span>{t('admin.channels.pendingChanges')}</span>
|
||||
<Button size="sm" disabled={applyMutation.isPending} onClick={() => applyMutation.mutate()}>
|
||||
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingsCard
|
||||
channel={channel}
|
||||
readyAssets={ready?.items ?? []}
|
||||
onSaved={invalidate}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
{template && (
|
||||
<CollapsibleCard title={t('admin.channels.grid')} defaultOpen>
|
||||
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.channels.layers')}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => addLayerMutation.mutate()}
|
||||
disabled={addLayerMutation.isPending}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<LayerList
|
||||
template={template}
|
||||
activeLayerId={layerForNewSlot ?? null}
|
||||
onSelect={(layer) => setActiveLayerId(layer.id)}
|
||||
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<ScheduleGrid
|
||||
template={template}
|
||||
selectedSlotId={draft?.slot?.id ?? null}
|
||||
onSelectSlot={openSlot}
|
||||
onAddSlot={openNewSlot}
|
||||
/>
|
||||
{draft && (
|
||||
<SlotInspector draft={draft} onClose={() => setDraft(null)} onChanged={invalidate} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)}
|
||||
|
||||
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
|
||||
|
||||
<SchedulePreview entries={schedule ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,245 +1,244 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
AdInsertion,
|
||||
BlockMode,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
HourWindow,
|
||||
OverrideMode,
|
||||
OverrideRecurrence,
|
||||
ScheduleEntryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
adInsertion: AdInsertion
|
||||
adsPerBreak: number
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export type ChannelShowBody = {
|
||||
showId: string
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
}
|
||||
|
||||
export function addChannelShow(id: string, body: ChannelShowBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/shows`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateChannelShow(
|
||||
id: string,
|
||||
channelShowId: string,
|
||||
body: {
|
||||
weight: number
|
||||
blockMode: BlockMode
|
||||
blockValue: number
|
||||
isEnabled: boolean
|
||||
preferredWeightMultiplier: number
|
||||
preferredHours: HourWindow[]
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function removeChannelShow(id: string, channelShowId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addChannelAd(id: string, mediaAssetId: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/ads`, {
|
||||
method: 'POST',
|
||||
body: { mediaAssetId },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeChannelAd(id: string, channelAdId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export type OverrideBody = {
|
||||
mode: OverrideMode
|
||||
recurrence: OverrideRecurrence
|
||||
startsAtUtc?: string | null
|
||||
endsAtUtc?: string | null
|
||||
dayOfWeek?: number | null
|
||||
startMinute?: number | null
|
||||
endMinute?: number | null
|
||||
shows: { showId: string; weight: number }[]
|
||||
}
|
||||
|
||||
export function createOverride(id: string, body: OverrideBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/overrides`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function deleteOverride(id: string, overrideId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function regenerateSchedule(id: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/regenerate`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
ApplyResultDto,
|
||||
BumperSettings,
|
||||
BumperTextKind,
|
||||
BumperTrigger,
|
||||
ChannelDto,
|
||||
ChannelSummaryDto,
|
||||
CreatedIdResponse,
|
||||
LayerApplicability,
|
||||
ScheduleEntryDto,
|
||||
ScheduleTemplateDto,
|
||||
SlotDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listChannels() {
|
||||
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
|
||||
}
|
||||
|
||||
export function getChannel(id: string) {
|
||||
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
|
||||
}
|
||||
|
||||
export function createChannel(body: { name: string; slug: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export type ChannelSettingsBody = {
|
||||
name: string
|
||||
isEnabled: boolean
|
||||
bumpersEnabled: boolean
|
||||
bumper: BumperSettings
|
||||
fillerAssetId: string | null
|
||||
}
|
||||
|
||||
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
|
||||
export function updateChannelTime(
|
||||
id: string,
|
||||
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
// ── Сетка канала ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getChannelTemplate(channelId: string) {
|
||||
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
|
||||
}
|
||||
|
||||
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
|
||||
export function applyChannelTemplate(channelId: string) {
|
||||
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTemplate(
|
||||
templateId: string,
|
||||
body: { name: string; fallbackGroupId: string | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function createLayer(templateId: string, body: { name: string; priority: number }) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function updateLayer(
|
||||
layerId: string,
|
||||
body: {
|
||||
name: string
|
||||
priority: number
|
||||
applicability: LayerApplicability | null
|
||||
isEnabled: boolean
|
||||
},
|
||||
) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteLayer(layerId: string) {
|
||||
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
|
||||
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
|
||||
|
||||
export function createSlot(layerId: string, body: SlotBody) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateSlot(slotId: string, body: SlotBody) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteSlot(slotId: string) {
|
||||
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export type BumperTemplateStyleBody = {
|
||||
name: string
|
||||
backgroundColor: string
|
||||
backgroundColor2: string
|
||||
accentColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
export function addBumperTemplate(id: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
|
||||
method: 'POST',
|
||||
body: { name },
|
||||
})
|
||||
}
|
||||
|
||||
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export function removeBumperTemplate(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
|
||||
function uploadBumperTemplateFile(
|
||||
id: string,
|
||||
templateId: string,
|
||||
kind: 'audio' | 'background',
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open(
|
||||
'PUT',
|
||||
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
|
||||
)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve()
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
|
||||
return uploadBumperTemplateFile(id, templateId, 'audio', file)
|
||||
}
|
||||
|
||||
export type BumperVariantBody = {
|
||||
name: string
|
||||
kind: BumperTextKind
|
||||
nowLabel: string
|
||||
nextLabel: string
|
||||
line1: string
|
||||
line2: string
|
||||
trigger: BumperTrigger
|
||||
weight: number
|
||||
}
|
||||
|
||||
export function addBumperVariant(id: string, templateId: string, name: string) {
|
||||
return apiRequest<CreatedIdResponse>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
|
||||
{ method: 'POST', body: { name } },
|
||||
)
|
||||
}
|
||||
|
||||
export function updateBumperVariant(
|
||||
id: string,
|
||||
templateId: string,
|
||||
variantId: string,
|
||||
body: BumperVariantBody,
|
||||
) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'PUT', body },
|
||||
)
|
||||
}
|
||||
|
||||
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
|
||||
return apiRequest<void>(
|
||||
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
|
||||
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateAudio(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function clearBumperTemplateBackground(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
|
||||
export function renderBumperPreviews(id: string, templateId: string) {
|
||||
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
|
||||
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
|
||||
}
|
||||
|
||||
export function getSchedule(id: string, from: Date, to: Date) {
|
||||
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
|
||||
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelAd } from '../api'
|
||||
|
||||
export function AddAdForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [assetId, setAssetId] = useState('')
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelAd(channelId, assetId),
|
||||
onSuccess: () => {
|
||||
setAssetId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={assetId} onValueChange={setAssetId}>
|
||||
<SelectTrigger className="max-w-md">
|
||||
<SelectValue placeholder={t('admin.channels.pickAd')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { addChannelShow } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function AddShowForm({
|
||||
channelId,
|
||||
options,
|
||||
onAdded,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onAdded: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>('Count')
|
||||
const [blockValue, setBlockValue] = useState(1)
|
||||
|
||||
const add = useMutation({
|
||||
mutationFn: () => addChannelShow(channelId, { showId, weight, blockMode, blockValue }),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
onAdded()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<NumberField
|
||||
label={blockMode === 'Count' ? t('admin.channels.episodes') : t('admin.channels.minutes')}
|
||||
value={blockValue}
|
||||
onChange={setBlockValue}
|
||||
min={1}
|
||||
/>
|
||||
<Button size="sm" disabled={!showId || add.isPending} onClick={() => add.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,181 +1,179 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
adInsertion: channel.adInsertion,
|
||||
adsPerBreak: channel.adsPerBreak,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BumperFont, BumperSelection, BumperSettings, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { addBumperTemplate, updateChannelSettings } from '../api'
|
||||
import { clampChance } from '../lib/format'
|
||||
import { BumperTemplateEditor } from './BumperTemplateEditor'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function BumperCard({
|
||||
channel,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
|
||||
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
|
||||
|
||||
const setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
|
||||
setBumper((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
useEffect(() => {
|
||||
setBumpersEnabled(channel.bumpersEnabled)
|
||||
setBumper(channel.bumper)
|
||||
}, [channel])
|
||||
|
||||
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
|
||||
// поля берём из канала без изменений (они правятся в своей карточке).
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: channel.name,
|
||||
isEnabled: channel.isEnabled,
|
||||
bumpersEnabled,
|
||||
bumper,
|
||||
fillerAssetId: channel.fillerAssetId,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const addTemplate = useMutation({
|
||||
mutationFn: () => addBumperTemplate(channel.id, ''),
|
||||
onSuccess: onSaved,
|
||||
onError,
|
||||
})
|
||||
|
||||
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
|
||||
|
||||
return (
|
||||
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={bumpersEnabled}
|
||||
onChange={(e) => setBumpersEnabled(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.channels.bumpersLabel')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumpersHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Общие настройки */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperSelection')}</Label>
|
||||
<Select value={bumper.selection} onValueChange={(v) => setField('selection', v as BumperSelection)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
|
||||
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
|
||||
<SelectItem value="WeightedRandom">
|
||||
{t('admin.channels.bumperSelectionWeighted')}
|
||||
</SelectItem>
|
||||
<SelectItem value="AlwaysFirst">
|
||||
{t('admin.channels.bumperSelectionAlwaysFirst')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperFont')}</Label>
|
||||
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
|
||||
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperMinInterval')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
value={bumper.minIntervalMinutes}
|
||||
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.showChangeChance}
|
||||
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperShowChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={bumper.episodeChangeChance}
|
||||
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.bumperEpisodeChangeChanceHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
key={template.id}
|
||||
channelId={channel.id}
|
||||
template={template}
|
||||
onChanged={onSaved}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button size="sm" variant="outline" disabled={addTemplate.isPending} onClick={() => addTemplate.mutate()}>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockMode, ChannelShowDto, HourWindow } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { removeChannelShow, updateChannelShow } from '../api'
|
||||
import { RemoveButton } from './fields'
|
||||
|
||||
export function ChannelShowRow({
|
||||
channelId,
|
||||
row,
|
||||
onChanged,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
row: ChannelShowDto
|
||||
onChanged: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [weight, setWeight] = useState(row.weight)
|
||||
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
|
||||
const [blockValue, setBlockValue] = useState(row.blockValue)
|
||||
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
|
||||
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelShow(channelId, row.id, {
|
||||
weight,
|
||||
blockMode,
|
||||
blockValue,
|
||||
isEnabled,
|
||||
preferredWeightMultiplier: multiplier,
|
||||
preferredHours: hours.filter((h) => h.startHour < h.endHour),
|
||||
}),
|
||||
onSuccess: onChanged,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
|
||||
const setHour = (i: number, patch: Partial<HourWindow>) =>
|
||||
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
|
||||
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td className="py-2">{row.showName}</td>
|
||||
<td className="py-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={weight}
|
||||
onChange={(e) => setWeight(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
|
||||
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
|
||||
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={blockValue}
|
||||
onChange={(e) => setBlockValue(Number(e.target.value))}
|
||||
className="h-8 w-16"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{t('admin.channels.preferredHours')}
|
||||
{hours.length > 0 ? ` (${hours.length})` : ''}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<RemoveButton
|
||||
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded && (
|
||||
<tr className="border-b border-border last:border-0">
|
||||
<td colSpan={5} className="bg-muted/30 py-3">
|
||||
<div className="flex flex-col gap-3 pl-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="whitespace-nowrap">{t('admin.channels.preferredMultiplier')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={multiplier}
|
||||
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.channels.preferredHoursHint')}
|
||||
</span>
|
||||
</div>
|
||||
{hours.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
|
||||
)}
|
||||
{hours.map((w, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<HourSelect value={w.startHour} from={0} to={23} onChange={(v) => setHour(i, { startHour: v })} />
|
||||
<span className="text-muted-foreground">—</span>
|
||||
<HourSelect value={w.endHour} from={1} to={24} onChange={(v) => setHour(i, { endHour: v })} />
|
||||
{w.startHour >= w.endHour && (
|
||||
<span className="text-xs text-destructive">
|
||||
{t('admin.channels.preferredBadRange')}
|
||||
</span>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<Button size="sm" variant="outline" onClick={addHour}>
|
||||
{t('admin.channels.preferredAddWindow')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
|
||||
function HourSelect({
|
||||
value,
|
||||
from,
|
||||
to,
|
||||
onChange,
|
||||
}: {
|
||||
value: number
|
||||
from: number
|
||||
to: number
|
||||
onChange: (v: number) => void
|
||||
}) {
|
||||
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
|
||||
return (
|
||||
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
|
||||
<SelectTrigger className="h-8 w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OverrideMode, OverrideRecurrence } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { createOverride } from '../api'
|
||||
import { NumberField } from './fields'
|
||||
|
||||
export function OverrideForm({
|
||||
channelId,
|
||||
options,
|
||||
onCreated,
|
||||
onError,
|
||||
}: {
|
||||
channelId: string
|
||||
options: { id: string; name: string }[]
|
||||
onCreated: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [mode, setMode] = useState<OverrideMode>('Exclusive')
|
||||
const [recurrence, setRecurrence] = useState<OverrideRecurrence>('OneTime')
|
||||
const [showId, setShowId] = useState('')
|
||||
const [weight, setWeight] = useState(1)
|
||||
const [start, setStart] = useState('')
|
||||
const [end, setEnd] = useState('')
|
||||
// Weekly: день недели (0=Вс..6=Сб) + окна времени суток «HH:MM».
|
||||
const [dayOfWeek, setDayOfWeek] = useState(6)
|
||||
const [startTime, setStartTime] = useState('')
|
||||
const [endTime, setEndTime] = useState('')
|
||||
|
||||
const toMinutes = (hhmm: string) => {
|
||||
const [h, m] = hhmm.split(':').map(Number)
|
||||
return h * 60 + m
|
||||
}
|
||||
const weekly = recurrence === 'Weekly'
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createOverride(
|
||||
channelId,
|
||||
weekly
|
||||
? {
|
||||
mode,
|
||||
recurrence,
|
||||
dayOfWeek,
|
||||
startMinute: toMinutes(startTime),
|
||||
endMinute: toMinutes(endTime),
|
||||
shows: [{ showId, weight }],
|
||||
}
|
||||
: {
|
||||
mode,
|
||||
recurrence,
|
||||
startsAtUtc: new Date(start).toISOString(),
|
||||
endsAtUtc: new Date(end).toISOString(),
|
||||
shows: [{ showId, weight }],
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
setShowId('')
|
||||
setStart('')
|
||||
setEnd('')
|
||||
setStartTime('')
|
||||
setEndTime('')
|
||||
onCreated()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const valid = weekly
|
||||
? showId && startTime && endTime && toMinutes(endTime) > toMinutes(startTime)
|
||||
: showId && start && end && new Date(end) > new Date(start)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overrideRecurrence')}</Label>
|
||||
<Select value={recurrence} onValueChange={(v) => setRecurrence(v as OverrideRecurrence)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="OneTime">{t('admin.channels.recurrenceOneTime')}</SelectItem>
|
||||
<SelectItem value="Weekly">{t('admin.channels.recurrenceWeekly')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select value={mode} onValueChange={(v) => setMode(v as OverrideMode)}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Exclusive">{t('admin.channels.modes.Exclusive')}</SelectItem>
|
||||
<SelectItem value="Boost">{t('admin.channels.modes.Boost')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t('admin.channels.pickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((o) => (
|
||||
<SelectItem key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mode === 'Boost' && (
|
||||
<NumberField label={t('admin.channels.weight')} value={weight} onChange={setWeight} min={1} />
|
||||
)}
|
||||
{weekly ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<Select value={String(dayOfWeek)} onValueChange={(v) => setDayOfWeek(Number(v))}>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>
|
||||
{t(`admin.channels.weekdays.${d}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.from')}</Label>
|
||||
<Input type="datetime-local" value={start} onChange={(e) => setStart(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.to')}</Label>
|
||||
<Input type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} className="w-60" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" disabled={!valid || create.isPending} onClick={() => create.mutate()}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Anchor, Plus } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
|
||||
const HOUR_HEIGHT = 44
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]
|
||||
|
||||
/** Цвет блока — по дейпарту: сетка должна читаться одним взглядом, без легенды. */
|
||||
const DAYPART_CLASS: Record<string, string> = {
|
||||
Morning: 'bg-amber-500/20 border-amber-500/40',
|
||||
Day: 'bg-sky-500/20 border-sky-500/40',
|
||||
Prime: 'bg-violet-500/25 border-violet-500/50',
|
||||
Night: 'bg-slate-500/20 border-slate-500/40',
|
||||
}
|
||||
|
||||
function minutesOf(time: string): number {
|
||||
const [h, m] = time.split(':')
|
||||
return Number(h) * 60 + Number(m)
|
||||
}
|
||||
|
||||
/**
|
||||
* Смещение слота от начала вещательных суток. Ночной блок (00:00–06:00 при старте суток в 06:00)
|
||||
* принадлежит предыдущему дню, поэтому его смещение больше суточного, а не отрицательное.
|
||||
*/
|
||||
function offsetInDay(slotStart: string, dayStart: string): number {
|
||||
const diff = minutesOf(slotStart) - minutesOf(dayStart)
|
||||
return diff >= 0 ? diff : diff + 24 * 60
|
||||
}
|
||||
|
||||
/** Слоты, попадающие в колонку дня: слот без дня недели идёт каждый день. */
|
||||
function slotsOfDay(layers: GridLayerDto[], weekday: number) {
|
||||
return layers
|
||||
.filter((layer) => layer.isEnabled)
|
||||
.flatMap((layer) =>
|
||||
layer.slots
|
||||
.filter((slot) => slot.weekday === null || slot.weekday === weekday)
|
||||
.map((slot) => ({ slot, layer })),
|
||||
)
|
||||
}
|
||||
|
||||
export function ScheduleGrid({
|
||||
template,
|
||||
selectedSlotId,
|
||||
onSelectSlot,
|
||||
onAddSlot,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
selectedSlotId: string | null
|
||||
onSelectSlot: (slot: SlotDto) => void
|
||||
onAddSlot: (weekday: number, startMinutes: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const dayStart = template.dayStartTime.slice(0, 5)
|
||||
const dayStartMinutes = minutesOf(dayStart)
|
||||
|
||||
// Подписи часов идут от начала вещательных суток, а не от полуночи.
|
||||
const hours = Array.from({ length: 24 }, (_, i) => (dayStartMinutes / 60 + i) % 24)
|
||||
|
||||
// Слои отсортированы по убыванию приоритета: слот, лежащий под более приоритетным, штрихуем.
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
|
||||
const isCovered = (slot: SlotDto, layer: GridLayerDto, weekday: number) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const to = from + slot.targetDurationMinutes
|
||||
return ordered
|
||||
.filter((other) => other.isEnabled && other.priority > layer.priority)
|
||||
.some((other) =>
|
||||
other.slots
|
||||
.filter((s) => s.weekday === null || s.weekday === weekday)
|
||||
.some((s) => {
|
||||
const otherFrom = offsetInDay(s.targetStart, dayStart)
|
||||
return from < otherFrom + s.targetDurationMinutes && otherFrom < to
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<div className="min-w-[720px]">
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)] border-b border-border text-xs text-muted-foreground">
|
||||
<div className="px-2 py-1">{dayStart}</div>
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div key={weekday} className="px-2 py-1 text-center font-medium">
|
||||
{t(`admin.channels.weekdays.${weekday}`)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[56px_repeat(7,1fr)]">
|
||||
<div>
|
||||
{hours.map((hour, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-b border-border/40 px-2 text-[11px] text-muted-foreground"
|
||||
style={{ height: HOUR_HEIGHT }}
|
||||
>
|
||||
{hour.toString().padStart(2, '0')}:00
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{WEEKDAYS.map((weekday) => (
|
||||
<div
|
||||
key={weekday}
|
||||
className="relative border-l border-border"
|
||||
style={{ height: HOUR_HEIGHT * 24 }}
|
||||
>
|
||||
{hours.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
title={t('admin.channels.addSlotHere')}
|
||||
className="group absolute inset-x-0 border-b border-border/40 hover:bg-muted/30"
|
||||
style={{ top: HOUR_HEIGHT * index, height: HOUR_HEIGHT }}
|
||||
onClick={() => onAddSlot(weekday, (dayStartMinutes + index * 60) % (24 * 60))}
|
||||
>
|
||||
<Plus className="mx-auto h-3 w-3 opacity-0 group-hover:opacity-40" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{slotsOfDay(ordered, weekday).map(({ slot, layer }) => {
|
||||
const from = offsetInDay(slot.targetStart, dayStart)
|
||||
const covered = isCovered(slot, layer, weekday)
|
||||
return (
|
||||
<button
|
||||
key={`${slot.id}-${weekday}`}
|
||||
type="button"
|
||||
onClick={() => onSelectSlot(slot)}
|
||||
className={cn(
|
||||
'absolute inset-x-1 overflow-hidden rounded border px-1.5 py-0.5 text-left text-[11px] leading-tight',
|
||||
DAYPART_CLASS[slot.daypart] ?? DAYPART_CLASS.Day,
|
||||
selectedSlotId === slot.id && 'ring-2 ring-primary',
|
||||
// Перекрытый слот виден, но приглушён: он не сыграет, пока лежит под старшим слоем.
|
||||
covered && 'opacity-40 [background-image:repeating-linear-gradient(45deg,transparent,transparent_4px,rgba(0,0,0,.15)_4px,rgba(0,0,0,.15)_8px)]',
|
||||
)}
|
||||
style={{
|
||||
top: (from / 60) * HOUR_HEIGHT,
|
||||
height: Math.max(16, (slot.targetDurationMinutes / 60) * HOUR_HEIGHT - 2),
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
{slot.isAnchor && <Anchor className="h-3 w-3 shrink-0" />}
|
||||
{slot.targetStart.slice(0, 5)}
|
||||
</span>
|
||||
<span className="block truncate">{slot.title}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Панель слоёв: видимость, приоритет и выбор редактируемого. */
|
||||
export function LayerList({
|
||||
template,
|
||||
activeLayerId,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
template: ScheduleTemplateDto
|
||||
activeLayerId: string | null
|
||||
onSelect: (layer: GridLayerDto) => void
|
||||
onDelete: (layer: GridLayerDto) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const ordered = [...template.layers].sort((a, b) => b.priority - a.priority)
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{ordered.map((layer) => (
|
||||
<li key={layer.id} className="flex items-center gap-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate text-left',
|
||||
activeLayerId === layer.id && 'text-primary',
|
||||
)}
|
||||
onClick={() => onSelect(layer)}
|
||||
>
|
||||
{layer.name}
|
||||
</button>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.isBackground ? t('admin.channels.background') : layer.priority}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{layer.slots.length}
|
||||
</span>
|
||||
{!layer.isBackground && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onDelete(layer)}>
|
||||
×
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
@@ -1,120 +1,133 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AdInsertion, ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
|
||||
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setAdInsertion(channel.adInsertion)
|
||||
setAdsPerBreak(channel.adsPerBreak)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
adInsertion,
|
||||
adsPerBreak,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adPolicy')}</Label>
|
||||
<Select value={adInsertion} onValueChange={(v) => setAdInsertion(v as AdInsertion)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BetweenBlocks">{t('admin.channels.betweenBlocks')}</SelectItem>
|
||||
<SelectItem value="BetweenEpisodes">{t('admin.channels.betweenEpisodes')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.adsPerBreak')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={adsPerBreak}
|
||||
onChange={(e) => setAdsPerBreak(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ChannelDto } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { updateChannelSettings, updateChannelTime } from '../api'
|
||||
import { CollapsibleCard } from './CollapsibleCard'
|
||||
|
||||
export function SettingsCard({
|
||||
channel,
|
||||
readyAssets,
|
||||
onSaved,
|
||||
onError,
|
||||
}: {
|
||||
channel: ChannelDto
|
||||
readyAssets: { id: string; originalFileName: string }[]
|
||||
onSaved: () => void
|
||||
onError: (e: unknown) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(channel.name)
|
||||
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
|
||||
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
|
||||
const [number, setNumber] = useState(channel.number?.toString() ?? '')
|
||||
const [offsetHours, setOffsetHours] = useState(channel.utcOffsetMinutes / 60)
|
||||
// Начало вещательных суток приходит как «06:00:00» — в поле нужен формат «06:00».
|
||||
const [dayStart, setDayStart] = useState(channel.dayStartTime.slice(0, 5))
|
||||
|
||||
useEffect(() => {
|
||||
setName(channel.name)
|
||||
setIsEnabled(channel.isEnabled)
|
||||
setFillerAssetId(channel.fillerAssetId ?? '')
|
||||
setNumber(channel.number?.toString() ?? '')
|
||||
setOffsetHours(channel.utcOffsetMinutes / 60)
|
||||
setDayStart(channel.dayStartTime.slice(0, 5))
|
||||
}, [channel])
|
||||
|
||||
const save = useMutation({
|
||||
// Время канала живёт отдельной командой — сохраняем обе за одно нажатие.
|
||||
mutationFn: async () => {
|
||||
await updateChannelSettings(channel.id, {
|
||||
name: name.trim(),
|
||||
isEnabled,
|
||||
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
|
||||
bumpersEnabled: channel.bumpersEnabled,
|
||||
bumper: channel.bumper,
|
||||
fillerAssetId: fillerAssetId || null,
|
||||
})
|
||||
await updateChannelTime(channel.id, {
|
||||
number: number.trim() === '' ? null : Number(number),
|
||||
utcOffsetMinutes: Math.round(offsetHours * 60),
|
||||
dayStartTime: `${dayStart}:00`,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.saved'))
|
||||
onSaved()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<CollapsibleCard
|
||||
title={t('admin.channels.settings')}
|
||||
defaultOpen
|
||||
contentClassName="grid gap-4 sm:grid-cols-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.name')}</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.number')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={number}
|
||||
placeholder={t('admin.channels.numberPlaceholder')}
|
||||
onChange={(e) => setNumber(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.utcOffset')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={-12}
|
||||
max={14}
|
||||
step={1}
|
||||
value={offsetHours}
|
||||
onChange={(e) => setOffsetHours(Number(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.utcOffsetHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.dayStart')}</Label>
|
||||
<Input type="time" value={dayStart} onChange={(e) => setDayStart(e.target.value)} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.dayStartHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.filler')}</Label>
|
||||
<Select
|
||||
value={fillerAssetId || 'none'}
|
||||
onValueChange={(v) => setFillerAssetId(v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">{t('admin.channels.noFiller')}</SelectItem>
|
||||
{readyAssets.map((a) => (
|
||||
<SelectItem key={a.id} value={a.id}>
|
||||
{a.originalFileName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={isEnabled} onChange={(e) => setIsEnabled(e.target.checked)} />
|
||||
{t('admin.channels.enabledLabel')}
|
||||
</label>
|
||||
<div className="flex items-end justify-end sm:col-span-2">
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGroups } from '@/features/admin/groups/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
Daypart,
|
||||
OverflowPolicy,
|
||||
SlotBlockMode,
|
||||
SlotDto,
|
||||
SlotKind,
|
||||
SlotStrategyType,
|
||||
} from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createSlot, deleteSlot, updateSlot, type SlotBody } from '../api'
|
||||
|
||||
const DAYPARTS: Daypart[] = ['Morning', 'Day', 'Prime', 'Night']
|
||||
const SLOT_KINDS: SlotKind[] = ['Content', 'Repeat', 'SignOff']
|
||||
const BLOCK_MODES: SlotBlockMode[] = ['Count', 'Duration', 'FillSlot']
|
||||
const OVERFLOW: OverflowPolicy[] = ['ContinueNext', 'ExtendSlot', 'SkipIfNotFits']
|
||||
const STRATEGIES: SlotStrategyType[] = ['Sequential', 'RandomWithCooldown', 'Fixed']
|
||||
const SNAP_OPTIONS = [0, 5, 10, 15, 30]
|
||||
|
||||
/** Черновик слота: новый (layerId + предзаполненные время/день) либо существующий. */
|
||||
export type SlotDraft = { layerId: string; slot: SlotDto | null; defaults?: Partial<SlotBody> }
|
||||
|
||||
function toBody(slot: SlotDto): SlotBody {
|
||||
const { id: _id, layerId: _layerId, groupName: _groupName, ...body } = slot
|
||||
return body
|
||||
}
|
||||
|
||||
function emptyBody(defaults?: Partial<SlotBody>): SlotBody {
|
||||
return {
|
||||
title: '',
|
||||
weekday: null,
|
||||
targetStart: '20:00:00',
|
||||
targetDurationMinutes: 60,
|
||||
daypart: 'Day',
|
||||
slotKind: 'Content',
|
||||
groupId: null,
|
||||
strategy: {
|
||||
type: 'Sequential',
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
},
|
||||
repeatSource: null,
|
||||
blockMode: 'FillSlot',
|
||||
blockValue: 1,
|
||||
overflowPolicy: 'ContinueNext',
|
||||
isAnchor: false,
|
||||
maxDriftMinutes: 5,
|
||||
snapToMinutes: null,
|
||||
...defaults,
|
||||
}
|
||||
}
|
||||
|
||||
export function SlotInspector({
|
||||
draft,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
draft: SlotDraft
|
||||
onClose: () => void
|
||||
onChanged: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [body, setBody] = useState<SlotBody>(() =>
|
||||
draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBody(draft.slot ? toBody(draft.slot) : emptyBody(draft.defaults))
|
||||
}, [draft])
|
||||
|
||||
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (draft.slot) await updateSlot(draft.slot.id, body)
|
||||
else await createSlot(draft.layerId, body)
|
||||
},
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => deleteSlot(draft.slot!.id),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
onClose()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const patch = (part: Partial<SlotBody>) => setBody((prev) => ({ ...prev, ...part }))
|
||||
const isContent = body.slotKind === 'Content'
|
||||
|
||||
return (
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{draft.slot ? t('admin.channels.editSlot') : t('admin.channels.newSlot')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotTitle')}</Label>
|
||||
<Input value={body.title} onChange={(e) => patch({ title: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotStart')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.targetStart.slice(0, 5)}
|
||||
onChange={(e) => patch({ targetStart: `${e.target.value}:00` })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.targetDurationMinutes}
|
||||
onChange={(e) => patch({ targetDurationMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.weekday')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.weekday ?? 'any'}
|
||||
onChange={(e) =>
|
||||
patch({ weekday: e.target.value === 'any' ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
<option value="any">{t('admin.channels.everyDay')}</option>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((day) => (
|
||||
<option key={day} value={day}>
|
||||
{t(`admin.channels.weekdays.${day}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.daypart')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.daypart}
|
||||
onChange={(e) => patch({ daypart: e.target.value as Daypart })}
|
||||
>
|
||||
{DAYPARTS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.dayparts.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.slotKind')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.slotKind}
|
||||
onChange={(e) => {
|
||||
const slotKind = e.target.value as SlotKind
|
||||
patch({
|
||||
slotKind,
|
||||
// Повтору нужен источник, конец вещания не берёт контент вовсе.
|
||||
repeatSource:
|
||||
slotKind === 'Repeat'
|
||||
? (body.repeatSource ?? { daysAgo: 1, time: '20:00:00', durationMinutes: 90 })
|
||||
: null,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{SLOT_KINDS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.slotKinds.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isContent && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.group')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.groupId ?? ''}
|
||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||
>
|
||||
<option value="">{t('admin.channels.pickGroup')}</option>
|
||||
{(groups ?? []).map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name} · {group.itemCount}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.strategy')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.strategy?.type ?? 'Sequential'}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: {
|
||||
...(body.strategy ?? {
|
||||
restartOnEnd: true,
|
||||
cooldownDays: 0,
|
||||
fallback: 'OldestFirst',
|
||||
type: 'Sequential',
|
||||
}),
|
||||
type: e.target.value as SlotStrategyType,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
{STRATEGIES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.strategies.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{body.strategy?.type === 'RandomWithCooldown' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.cooldownDays')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={body.strategy.cooldownDays}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
strategy: { ...body.strategy!, cooldownDays: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.cooldownHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockMode')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.blockMode}
|
||||
onChange={(e) => patch({ blockMode: e.target.value as SlotBlockMode })}
|
||||
>
|
||||
{BLOCK_MODES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.blockModes.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{body.blockMode !== 'FillSlot' && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.blockValue')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.blockValue}
|
||||
onChange={(e) => patch({ blockValue: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.overflow')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.overflowPolicy}
|
||||
onChange={(e) => patch({ overflowPolicy: e.target.value as OverflowPolicy })}
|
||||
>
|
||||
{OVERFLOW.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.channels.overflows.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.overflowHint')}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{body.slotKind === 'Repeat' && body.repeatSource && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDaysAgo')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.daysAgo}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: { ...body.repeatSource!, daysAgo: Number(e.target.value) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatTime')}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={body.repeatSource.time.slice(0, 5)}
|
||||
onChange={(e) =>
|
||||
patch({ repeatSource: { ...body.repeatSource!, time: `${e.target.value}:00` } })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.repeatDuration')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={body.repeatSource.durationMinutes}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
repeatSource: {
|
||||
...body.repeatSource!,
|
||||
durationMinutes: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={body.isAnchor}
|
||||
onChange={(e) => patch({ isAnchor: e.target.checked })}
|
||||
/>
|
||||
{t('admin.channels.anchor')}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.maxDrift')}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-24"
|
||||
value={body.maxDriftMinutes}
|
||||
onChange={(e) => patch({ maxDriftMinutes: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.channels.snap')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={body.snapToMinutes ?? 0}
|
||||
onChange={(e) =>
|
||||
patch({ snapToMinutes: Number(e.target.value) === 0 ? null : Number(e.target.value) })
|
||||
}
|
||||
>
|
||||
{SNAP_OPTIONS.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value === 0 ? t('admin.channels.snapOff') : `${value}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.anchorHint')}</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{draft.slot ? (
|
||||
<Button size="sm" variant="destructive" onClick={() => remove.mutate()}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, GripVertical, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { ImageGallery } from '@/features/admin/images/ImageGallery'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
addCollectionShow,
|
||||
getCollection,
|
||||
removeCollectionShow,
|
||||
reorderCollection,
|
||||
setCollectionPoster,
|
||||
updateCollection,
|
||||
} from './api'
|
||||
|
||||
export function CollectionDetail({ collectionId }: { collectionId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [galleryOpen, setGalleryOpen] = useState(false)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
const [pendingShow, setPendingShow] = useState('')
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [description, setDescription] = useState<string | null>(null)
|
||||
|
||||
const { data: collection, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'collections', collectionId],
|
||||
queryFn: () => getCollection(collectionId),
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateCollection(collectionId, {
|
||||
name: (name ?? collection?.name ?? '').trim(),
|
||||
description: description ?? collection?.description ?? null,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const posterMutation = useMutation({
|
||||
mutationFn: (imageId: string | null) => setCollectionPoster(collectionId, imageId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (showId: string) => addCollectionShow(collectionId, showId),
|
||||
onSuccess: () => {
|
||||
setPendingShow('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (showId: string) => removeCollectionShow(collectionId, showId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderCollection(collectionId, order),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !collection)
|
||||
return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const memberIds = new Set(collection.items.map((i) => i.showId))
|
||||
const available = (shows ?? []).filter((s) => !memberIds.has(s.id))
|
||||
|
||||
/** Порядок пересобирается целиком и уходит одним запросом — сервер сам расставит позиции. */
|
||||
const dropOn = (targetShowId: string) => {
|
||||
if (!dragged || dragged === targetShowId) return
|
||||
const order = collection.items.map((i) => i.showId).filter((id) => id !== dragged)
|
||||
const at = order.indexOf(targetShowId)
|
||||
order.splice(at, 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/collections">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.collections.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<div className="flex w-40 shrink-0 flex-col gap-2">
|
||||
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
|
||||
{collection.posterImageId ? (
|
||||
<img
|
||||
src={imageUrl(collection.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
|
||||
{t('admin.metadata.pickPoster')}
|
||||
</Button>
|
||||
{collection.posterImageId && (
|
||||
<Button size="sm" variant="ghost" onClick={() => posterMutation.mutate(null)}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<ImageGallery
|
||||
open={galleryOpen}
|
||||
onOpenChange={setGalleryOpen}
|
||||
category="ShowPoster"
|
||||
onSelect={(img) => posterMutation.mutate(img.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.collections.name')}</Label>
|
||||
<Input
|
||||
value={name ?? collection.name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.collections.description')}</Label>
|
||||
<Input
|
||||
value={description ?? collection.description ?? ''}
|
||||
maxLength={2048}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{t('admin.collections.parts')}
|
||||
</h3>
|
||||
<Select value={pendingShow} onValueChange={setPendingShow}>
|
||||
<SelectTrigger className="w-64">
|
||||
<SelectValue placeholder={t('admin.collections.addShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{available.map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!pendingShow || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate(pendingShow)}
|
||||
>
|
||||
{t('admin.collections.addShow')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.collections.orderHint')}</p>
|
||||
|
||||
<div className="crt-panel rounded-md">
|
||||
{collection.items.length === 0 ? (
|
||||
<p className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{t('admin.collections.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{collection.items.map((item, index) => (
|
||||
<li
|
||||
key={item.showId}
|
||||
draggable
|
||||
onDragStart={() => setDragged(item.showId)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(item.showId)}
|
||||
className="flex items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||
<Link
|
||||
to="/admin/shows/$showId"
|
||||
params={{ showId: item.showId }}
|
||||
className="min-w-0 flex-1 truncate text-primary hover:underline"
|
||||
>
|
||||
{item.showName}
|
||||
</Link>
|
||||
{item.year && <span className="text-muted-foreground">{item.year}</span>}
|
||||
<Badge variant="muted">{t(`admin.shows.kinds.${item.showKind}`)}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.shows.episodes')}: {item.episodeCount}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeMutation.mutate(item.showId)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createCollection, deleteCollection, listCollections } from './api'
|
||||
|
||||
export function CollectionsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'collections'],
|
||||
queryFn: listCollections,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createCollection({ name: name.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteCollection,
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
name: (c) => c.name.toLowerCase(),
|
||||
items: (c) => c.itemCount,
|
||||
units: (c) => c.unitCount,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.collections.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.collections.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.collections.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-16 px-4 py-2 font-medium" />
|
||||
<SortHeader
|
||||
label={t('admin.collections.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.collections.parts')}
|
||||
sortKey="items"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.collections.units')}
|
||||
sortKey="units"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((collection) => (
|
||||
<tr key={collection.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex h-12 w-8 items-center justify-center overflow-hidden rounded border border-border bg-muted/30">
|
||||
{collection.posterImageId && (
|
||||
<img
|
||||
src={imageUrl(collection.posterImageId)}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to="/admin/collections/$collectionId"
|
||||
params={{ collectionId: collection.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{collection.itemCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{collection.unitCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(collection.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types'
|
||||
|
||||
export function listCollections() {
|
||||
return apiRequest<CollectionSummaryDto[]>('/admin/collections')
|
||||
}
|
||||
|
||||
export function getCollection(id: string) {
|
||||
return apiRequest<CollectionDto>(`/admin/collections/${id}`)
|
||||
}
|
||||
|
||||
export function createCollection(body: { name: string; description?: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/collections', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateCollection(id: string, body: { name: string; description: string | null }) {
|
||||
return apiRequest<void>(`/admin/collections/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteCollection(id: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addCollectionShow(id: string, showId: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
|
||||
}
|
||||
|
||||
export function removeCollectionShow(id: string, showId: string) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/shows/${showId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Порядок частей: не упомянутые остаются после перечисленных. */
|
||||
export function reorderCollection(id: string, showIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/order`, {
|
||||
method: 'PUT',
|
||||
body: { showIdsInOrder },
|
||||
})
|
||||
}
|
||||
|
||||
/** Привязать/снять постер коллекции (null — отвязать). */
|
||||
export function setCollectionPoster(id: string, imageId: string | null) {
|
||||
return apiRequest<void>(`/admin/collections/${id}/poster-image`, {
|
||||
method: 'PUT',
|
||||
body: { imageId },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GenreDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9-]+$/),
|
||||
aliases: z.string().max(1024).optional(),
|
||||
})
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string().min(1).max(128),
|
||||
sortOrder: z.number().int().min(0),
|
||||
aliases: z.string().max(1024).optional(),
|
||||
})
|
||||
|
||||
type CreateValues = z.infer<typeof createSchema>
|
||||
type EditValues = z.infer<typeof editSchema>
|
||||
|
||||
/** Псевдонимы вводятся одной строкой через запятую — их редко больше десятка. */
|
||||
function parseAliases(value: string | undefined): string[] {
|
||||
return (value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
export function GenresPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: genres, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'genres'],
|
||||
queryFn: listGenres,
|
||||
})
|
||||
const { sort, toggle } = useTableSort('sortOrder', false)
|
||||
const sortedGenres = sortRows(genres ?? [], sort, {
|
||||
sortOrder: (g) => g.sortOrder,
|
||||
name: (g) => g.name.toLowerCase(),
|
||||
slug: (g) => g.slug,
|
||||
showCount: (g) => g.showCount,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'genres'] })
|
||||
const reportError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
...body
|
||||
}: {
|
||||
id: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
aliases: string[]
|
||||
}) => updateGenre(id, body),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteGenre,
|
||||
onSuccess: invalidate,
|
||||
onError: reportError,
|
||||
})
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<GenreDto | null>(null)
|
||||
|
||||
const createForm = useForm<CreateValues>({ resolver: zodResolver(createSchema) })
|
||||
const editForm = useForm<EditValues>({ resolver: zodResolver(editSchema) })
|
||||
|
||||
const openEdit = (genre: GenreDto) => {
|
||||
setEditing(genre)
|
||||
editForm.reset({
|
||||
name: genre.name,
|
||||
sortOrder: genre.sortOrder,
|
||||
aliases: genre.aliases.join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
const onCreate = async (values: CreateValues) => {
|
||||
try {
|
||||
await createMutation.mutateAsync({
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
aliases: parseAliases(values.aliases),
|
||||
})
|
||||
createForm.reset()
|
||||
setCreateOpen(false)
|
||||
} catch (error) {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const onEdit = async (values: EditValues) => {
|
||||
if (!editing) return
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editing.id,
|
||||
name: values.name,
|
||||
sortOrder: values.sortOrder,
|
||||
aliases: parseAliases(values.aliases),
|
||||
})
|
||||
setEditing(null)
|
||||
} catch (error) {
|
||||
reportError(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.genres.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.genres.hint')}</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" /> {t('admin.genres.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<SortHeader
|
||||
label={t('admin.genres.order')}
|
||||
sortKey="sortOrder"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.genres.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.genres.slug')}
|
||||
sortKey="slug"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('admin.genres.aliases')}</th>
|
||||
<SortHeader
|
||||
label={t('admin.genres.usage')}
|
||||
sortKey="showCount"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{sortedGenres.map((genre) => (
|
||||
<tr key={genre.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2 text-muted-foreground">{genre.sortOrder}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{genre.name}
|
||||
{genre.isSystem && <Badge variant="muted">{t('admin.genres.system')}</Badge>}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{genre.slug}</td>
|
||||
<td className="max-w-md px-4 py-2 text-xs text-muted-foreground">
|
||||
{genre.aliases.join(', ')}
|
||||
</td>
|
||||
<td className="px-4 py-2">{genre.showCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(genre)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={genre.isSystem || genre.showCount > 0}
|
||||
onClick={() => deleteMutation.mutate(genre.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.genres.create')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={createForm.handleSubmit(onCreate)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreName">{t('admin.genres.name')}</Label>
|
||||
<Input id="genreName" {...createForm.register('name')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreSlug">{t('admin.genres.slug')}</Label>
|
||||
<Input id="genreSlug" placeholder="action" {...createForm.register('slug')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.slugHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="genreAliases">{t('admin.genres.aliases')}</Label>
|
||||
<Input
|
||||
id="genreAliases"
|
||||
placeholder="tmdb:28, action"
|
||||
{...createForm.register('aliases')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.genres.edit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="flex flex-col gap-4" onSubmit={editForm.handleSubmit(onEdit)}>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreName">{t('admin.genres.name')}</Label>
|
||||
<Input id="editGenreName" {...editForm.register('name')} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreOrder">{t('admin.genres.order')}</Label>
|
||||
<Input
|
||||
id="editGenreOrder"
|
||||
type="number"
|
||||
{...editForm.register('sortOrder', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="editGenreAliases">{t('admin.genres.aliases')}</Label>
|
||||
<Input id="editGenreAliases" {...editForm.register('aliases')} />
|
||||
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CreatedIdResponse, GenreDto } from '@/shared/api/types'
|
||||
|
||||
export function listGenres() {
|
||||
return apiRequest<GenreDto[]>('/admin/genres')
|
||||
}
|
||||
|
||||
export function createGenre(body: { name: string; slug: string; aliases?: string[] }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/genres', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateGenre(
|
||||
id: string,
|
||||
body: { name: string; sortOrder: number; aliases?: string[] },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteGenre(id: string) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { splitDuration } from './format'
|
||||
|
||||
/** Объём эфира: «118 ч 40 мин». Ноль — прочерк, потому что «0 ч» читается как сбой подсчёта. */
|
||||
export function DurationLabel({ seconds }: { seconds: number }) {
|
||||
const { t } = useTranslation()
|
||||
const parts = splitDuration(seconds)
|
||||
if (!parts) return <>—</>
|
||||
|
||||
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.groups.hoursShort')}` : ''
|
||||
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.groups.minutesShort')}` : ''
|
||||
return <>{[hours, minutes].filter(Boolean).join(' ')}</>
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { ChevronLeft, GripVertical, Search, Trash2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import {
|
||||
addGroupElements,
|
||||
findGroupCandidates,
|
||||
getGroup,
|
||||
removeGroupItem,
|
||||
reorderGroup,
|
||||
setGroupItemWeight,
|
||||
updateGroup,
|
||||
} from './api'
|
||||
import { DurationLabel } from './DurationLabel'
|
||||
import { GroupFilterPanel } from './GroupFilterPanel'
|
||||
|
||||
const EMPTY_FILTER: GroupFilter = {}
|
||||
|
||||
export function GroupDetail({ groupId }: { groupId: string }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [name, setName] = useState<string | null>(null)
|
||||
const [description, setDescription] = useState<string | null>(null)
|
||||
const [filter, setFilter] = useState<GroupFilter | null>(null)
|
||||
const [candidates, setCandidates] = useState<GroupCandidateDto[] | null>(null)
|
||||
const [showWeights, setShowWeights] = useState(false)
|
||||
const [dragged, setDragged] = useState<string | null>(null)
|
||||
|
||||
const { data: group, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'groups', groupId],
|
||||
queryFn: () => getGroup(groupId),
|
||||
})
|
||||
|
||||
// Черновик правила поднимаем из сохранённого один раз — дальше им владеет форма.
|
||||
useEffect(() => {
|
||||
if (group && filter === null) setFilter(group.filter ?? EMPTY_FILTER)
|
||||
}, [group, filter])
|
||||
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||
}
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateGroup(groupId, {
|
||||
name: (name ?? group?.name ?? '').trim(),
|
||||
description: description ?? group?.description ?? null,
|
||||
filter: filter ?? null,
|
||||
}),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
const findMutation = useMutation({
|
||||
mutationFn: () => findGroupCandidates(groupId, filter ?? EMPTY_FILTER),
|
||||
onSuccess: setCandidates,
|
||||
onError,
|
||||
})
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (elements: GroupCandidateDto[]) =>
|
||||
addGroupElements(
|
||||
groupId,
|
||||
elements.map((c) => ({ elementKind: c.elementKind, elementId: c.elementId })),
|
||||
),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('admin.groups.added', { count: result.added }))
|
||||
setCandidates(null)
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (itemId: string) => removeGroupItem(groupId, itemId),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const weightMutation = useMutation({
|
||||
mutationFn: ({ itemId, weight }: { itemId: string; weight: number }) =>
|
||||
setGroupItemWeight(groupId, itemId, weight),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const reorderMutation = useMutation({
|
||||
mutationFn: (order: string[]) => reorderGroup(groupId, order),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
if (isLoading || !group) return <p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
|
||||
const dropOn = (targetItemId: string) => {
|
||||
if (!dragged || dragged === targetItemId) return
|
||||
const order = group.items.map((i) => i.id).filter((id) => id !== dragged)
|
||||
order.splice(order.indexOf(targetItemId), 0, dragged)
|
||||
setDragged(null)
|
||||
reorderMutation.mutate(order)
|
||||
}
|
||||
|
||||
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link to="/admin/groups">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
{t('admin.groups.title')}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.name')}</Label>
|
||||
<Input
|
||||
className="w-64"
|
||||
value={name ?? group.name}
|
||||
maxLength={256}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.description')}</Label>
|
||||
<Input
|
||||
value={description ?? group.description ?? ''}
|
||||
maxLength={2048}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="muted">
|
||||
{t('admin.groups.items')}: {group.itemCount}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
{t('admin.groups.units')}: {group.unitCount}
|
||||
</Badge>
|
||||
<Badge variant="muted">
|
||||
<DurationLabel seconds={group.totalDurationSeconds} />
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Левая панель — конструктор правила набора */}
|
||||
<div className="crt-panel flex flex-col gap-4 rounded-md p-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{t('admin.groups.filter.title')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.hint')}</p>
|
||||
</div>
|
||||
|
||||
<GroupFilterPanel filter={filter ?? EMPTY_FILTER} onChange={setFilter} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" disabled={findMutation.isPending} onClick={() => findMutation.mutate()}>
|
||||
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
||||
</Button>
|
||||
{candidates !== null && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={fresh.length === 0 || addMutation.isPending}
|
||||
onClick={() => addMutation.mutate(fresh)}
|
||||
>
|
||||
{t('admin.groups.addFound')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{candidates !== null && candidates.length > 0 && (
|
||||
<ul className="max-h-72 divide-y divide-border overflow-y-auto rounded border border-border text-sm">
|
||||
{candidates.map((candidate) => (
|
||||
<li
|
||||
key={`${candidate.elementKind}:${candidate.elementId}`}
|
||||
className="flex items-center gap-2 px-3 py-1.5"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{candidate.elementName}</span>
|
||||
{candidate.year && (
|
||||
<span className="text-muted-foreground">{candidate.year}</span>
|
||||
)}
|
||||
<Badge variant="muted">
|
||||
{t(`admin.groups.elementKinds.${candidate.elementKind}`)}
|
||||
</Badge>
|
||||
{candidate.alreadyInGroup && (
|
||||
<Badge variant="muted">{t('admin.groups.alreadyIn')}</Badge>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Правая панель — состав */}
|
||||
<div className="crt-panel flex flex-col gap-3 rounded-md p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide">
|
||||
{t('admin.groups.composition')}
|
||||
</h3>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowWeights((v) => !v)}>
|
||||
{showWeights ? t('admin.groups.hideAdvanced') : t('admin.groups.showAdvanced')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.orderHint')}</p>
|
||||
|
||||
{group.items.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.empty')}</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border text-sm">
|
||||
{group.items.map((item, index) => (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={() => setDragged(item.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => dropOn(item.id)}
|
||||
className="flex items-center gap-2 py-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
|
||||
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{item.elementName}</span>
|
||||
<Badge variant="muted">
|
||||
{t(`admin.groups.elementKinds.${item.elementKind}`)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.groups.units')}: {item.unitCount}
|
||||
</span>
|
||||
{showWeights && (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
className="h-8 w-20"
|
||||
defaultValue={item.weight}
|
||||
onBlur={(e) => {
|
||||
const weight = Number(e.target.value)
|
||||
if (Number.isFinite(weight) && weight !== item.weight)
|
||||
weightMutation.mutate({ itemId: item.id, weight })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={() => removeMutation.mutate(item.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES } from '@/shared/api/types'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
|
||||
const ELEMENT_KINDS: GroupElementKind[] = ['Show', 'Collection']
|
||||
const SHOW_KINDS: ShowKind[] = ['Series', 'Single']
|
||||
|
||||
/** Конструктор правила набора. Правило не применяется само — оно только ищет кандидатов. */
|
||||
export function GroupFilterPanel({
|
||||
filter,
|
||||
onChange,
|
||||
}: {
|
||||
filter: GroupFilter
|
||||
onChange: (next: GroupFilter) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
|
||||
|
||||
const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part })
|
||||
|
||||
const toggleIn = <T,>(list: T[] | null | undefined, value: T): T[] => {
|
||||
const current = list ?? []
|
||||
return current.includes(value) ? current.filter((x) => x !== value) : [...current, value]
|
||||
}
|
||||
|
||||
// Пустая строка означает «не ограничивать», поэтому 0 и «не задано» различаются явно.
|
||||
const numberOrNull = (value: string) => (value.trim() === '' ? null : Number(value))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 text-sm">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.elementKinds')}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ELEMENT_KINDS.map((kind) => (
|
||||
<label key={kind} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.elementKinds ?? []).includes(kind)}
|
||||
onChange={() => patch({ elementKinds: toggleIn(filter.elementKinds, kind) })}
|
||||
/>
|
||||
{t(`admin.groups.elementKinds.${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.showKinds')}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{SHOW_KINDS.map((kind) => (
|
||||
<label key={kind} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.showKinds ?? []).includes(kind)}
|
||||
onChange={() => patch({ showKinds: toggleIn(filter.showKinds, kind) })}
|
||||
/>
|
||||
{t(`admin.shows.kinds.${kind}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.genres')}</Label>
|
||||
<div className="flex max-h-40 flex-wrap gap-x-3 gap-y-1 overflow-y-auto">
|
||||
{(genres ?? []).map((genre) => (
|
||||
<label key={genre.id} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filter.genreIds ?? []).includes(genre.id)}
|
||||
onChange={() => patch({ genreIds: toggleIn(filter.genreIds, genre.id) })}
|
||||
/>
|
||||
{genre.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.genresHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.maxAudience')}</Label>
|
||||
<select
|
||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||
value={filter.maxAudience ?? ''}
|
||||
onChange={(e) =>
|
||||
patch({ maxAudience: e.target.value === '' ? null : (e.target.value as ShowAudience) })
|
||||
}
|
||||
>
|
||||
<option value="">{t('admin.groups.filter.any')}</option>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.audienceHint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.year')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.from')}
|
||||
value={filter.yearMin ?? ''}
|
||||
onChange={(e) => patch({ yearMin: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.to')}
|
||||
value={filter.yearMax ?? ''}
|
||||
onChange={(e) => patch({ yearMax: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.groups.filter.unitMinutes')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.from')}
|
||||
value={filter.unitMinutesMin ?? ''}
|
||||
onChange={(e) => patch({ unitMinutesMin: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-28"
|
||||
placeholder={t('admin.groups.filter.to')}
|
||||
value={filter.unitMinutesMax ?? ''}
|
||||
onChange={(e) => patch({ unitMinutesMax: numberOrNull(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.groups.filter.unitMinutesHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { createGroup, deleteGroup, listGroups } from './api'
|
||||
import { DurationLabel } from './DurationLabel'
|
||||
|
||||
export function GroupsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createGroup({ name: name.trim() }),
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError })
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
name: (g) => g.name.toLowerCase(),
|
||||
items: (g) => g.itemCount,
|
||||
units: (g) => g.unitCount,
|
||||
duration: (g) => g.totalDurationSeconds,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.groups.title')}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.groups.hint')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('admin.groups.name')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!name.trim() || createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
<tr>
|
||||
<SortHeader
|
||||
label={t('admin.groups.name')}
|
||||
sortKey="name"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.items')}
|
||||
sortKey="items"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.units')}
|
||||
sortKey="units"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.groups.duration')}
|
||||
sortKey="duration"
|
||||
sort={sort}
|
||||
onToggle={toggle}
|
||||
/>
|
||||
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{rows.map((group) => (
|
||||
<tr key={group.id} className="border-b border-border last:border-0">
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to="/admin/groups/$groupId"
|
||||
params={{ groupId: group.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
{group.hasFilter && (
|
||||
<Badge variant="muted">{t('admin.groups.hasFilter')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{group.itemCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{group.unitCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">
|
||||
<DurationLabel seconds={group.totalDurationSeconds} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => deleteMutation.mutate(group.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
GroupCandidateDto,
|
||||
GroupDto,
|
||||
GroupElementKind,
|
||||
GroupFilter,
|
||||
GroupSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listGroups() {
|
||||
return apiRequest<GroupSummaryDto[]>('/admin/groups')
|
||||
}
|
||||
|
||||
export function getGroup(id: string) {
|
||||
return apiRequest<GroupDto>(`/admin/groups/${id}`)
|
||||
}
|
||||
|
||||
export function createGroup(body: { name: string; description?: string }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/groups', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateGroup(
|
||||
id: string,
|
||||
body: { name: string; description: string | null; filter: GroupFilter | null },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/groups/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteGroup(id: string) {
|
||||
return apiRequest<void>(`/admin/groups/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Подбор по правилу набора. Фильтр передаётся явно — редактор крутит его до сохранения. */
|
||||
export function findGroupCandidates(id: string, filter: GroupFilter | null) {
|
||||
return apiRequest<GroupCandidateDto[]>(`/admin/groups/${id}/candidates`, {
|
||||
method: 'POST',
|
||||
body: { filter },
|
||||
})
|
||||
}
|
||||
|
||||
export function addGroupElements(
|
||||
id: string,
|
||||
elements: { elementKind: GroupElementKind; elementId: string }[],
|
||||
) {
|
||||
return apiRequest<{ added: number }>(`/admin/groups/${id}/items`, {
|
||||
method: 'POST',
|
||||
body: { elements },
|
||||
})
|
||||
}
|
||||
|
||||
export function removeGroupItem(id: string, itemId: string) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function setGroupItemWeight(id: string, itemId: string, weight: number) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/items/${itemId}/weight`, {
|
||||
method: 'PUT',
|
||||
body: { weight },
|
||||
})
|
||||
}
|
||||
|
||||
/** Порядок позиций: не упомянутые остаются после перечисленных. */
|
||||
export function reorderGroup(id: string, itemIdsInOrder: string[]) {
|
||||
return apiRequest<void>(`/admin/groups/${id}/order`, {
|
||||
method: 'PUT',
|
||||
body: { itemIdsInOrder },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Разбивает объём эфира на часы и минуты. Единицы измерения подставляет вызывающий из переводов —
|
||||
* функция намеренно не знает языка.
|
||||
*/
|
||||
export function splitDuration(totalSeconds: number): { hours: number; minutes: number } | null {
|
||||
if (!totalSeconds || totalSeconds <= 0) return null
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export function MaintenancePanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showId, setShowId] = useState('')
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
@@ -30,7 +30,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
|
||||
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { MediaAssetDto, ShowAudience } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
parseEpisodeName,
|
||||
} from '@/features/admin/media/episode-parse'
|
||||
import { formatDuration } from '@/features/admin/media/MediaPanel'
|
||||
import { ShowGenresField } from './ShowGenresField'
|
||||
import { ShowMetadataCard } from './ShowMetadataCard'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
import { addEpisode, getShow, removeEpisode, setShowAudience } from './api'
|
||||
@@ -156,9 +157,11 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
|
||||
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
|
||||
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{show.kind === 'Series' && (
|
||||
@@ -175,6 +178,24 @@ export function ShowDetail({ showId }: { showId: string }) {
|
||||
{t('admin.shows.loadedSeasons')}: {seasons.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<ShowGenresField show={show} onChanged={invalidate} />
|
||||
</div>
|
||||
{show.collections.length > 0 && (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
{t('admin.shows.inCollections')}:
|
||||
{show.collections.map((collection) => (
|
||||
<Link
|
||||
key={collection.id}
|
||||
to="/admin/collections/$collectionId"
|
||||
params={{ collectionId: collection.id }}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{collection.name}
|
||||
</Link>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ShowMetadataCard show={show} onChanged={invalidate} />
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { Tag } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ShowDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { setShowGenres } from './api'
|
||||
|
||||
/**
|
||||
* Жанры шоу: бейджи в шапке карточки + диалог правки. Основной жанр отмечается отдельно —
|
||||
* он показывается в списке шоу и участвует в отборе контента наравне с остальными.
|
||||
*/
|
||||
export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [primary, setPrimary] = useState<string | null>(null)
|
||||
|
||||
const { data: genres } = useQuery({
|
||||
queryKey: ['admin', 'genres'],
|
||||
queryFn: listGenres,
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => setShowGenres(show.id, selected, primary),
|
||||
onSuccess: () => {
|
||||
onChanged()
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
const openDialog = () => {
|
||||
setSelected(show.genres.map((g) => g.id))
|
||||
setPrimary(show.genres.find((g) => g.isPrimary)?.id ?? null)
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const toggle = (id: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
// Снятый жанр не может остаться основным — иначе сервер молча выберет другой.
|
||||
if (!next.includes(id) && primary === id) setPrimary(next[0] ?? null)
|
||||
if (next.includes(id) && primary === null) setPrimary(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{show.genres.length === 0 ? (
|
||||
<span className="text-sm text-muted-foreground">{t('admin.shows.genresEmpty')}</span>
|
||||
) : (
|
||||
show.genres.map((genre) => (
|
||||
<Badge key={genre.id} variant={genre.isPrimary ? 'default' : 'muted'}>
|
||||
{genre.name}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={openDialog}>
|
||||
<Tag className="h-4 w-4" /> {t('admin.shows.genresEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.shows.genresEdit')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.shows.genresHint')}</p>
|
||||
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
|
||||
{(genres ?? []).map((genre) => {
|
||||
const checked = selected.includes(genre.id)
|
||||
return (
|
||||
<div
|
||||
key={genre.id}
|
||||
className="flex items-center justify-between gap-3 rounded px-2 py-1 hover:bg-muted/40"
|
||||
>
|
||||
<label className="flex flex-1 items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={checked} onChange={() => toggle(genre.id)} />
|
||||
{genre.name}
|
||||
</label>
|
||||
{checked && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="primaryGenre"
|
||||
checked={primary === genre.id}
|
||||
onChange={() => setPrimary(genre.id)}
|
||||
/>
|
||||
{t('admin.shows.genrePrimary')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ShowAudience, ShowKind } from '@/shared/api/types'
|
||||
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
@@ -11,6 +11,7 @@ import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { listGenres } from '@/features/admin/genres/api'
|
||||
import { createShow, deleteShow, listShows } from './api'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -30,7 +31,13 @@ export function ShowsPanel() {
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
|
||||
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
|
||||
const [genreFilter, setGenreFilter] = useState('all')
|
||||
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'shows', { genreId: genreFilter }],
|
||||
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
|
||||
})
|
||||
|
||||
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
|
||||
const filtered = useMemo(() => {
|
||||
@@ -46,6 +53,7 @@ export function ShowsPanel() {
|
||||
name: (s) => s.name.toLowerCase(),
|
||||
kind: (s) => s.kind,
|
||||
audience: (s) => s.audience,
|
||||
genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
|
||||
seasons: (s) => s.seasonCount,
|
||||
episodes: (s) => s.episodeCount,
|
||||
})
|
||||
@@ -104,9 +112,11 @@ export function ShowsPanel() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
|
||||
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
|
||||
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
|
||||
{SHOW_AUDIENCES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t(`admin.shows.audiences.${value}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
@@ -118,15 +128,36 @@ export function ShowsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setQuery(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setPage(1)
|
||||
setQuery(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
value={genreFilter}
|
||||
onValueChange={(v) => {
|
||||
setPage(1)
|
||||
setGenreFilter(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('admin.shows.allGenres')}</SelectItem>
|
||||
{(genres ?? []).map((genre) => (
|
||||
<SelectItem key={genre.id} value={genre.id}>
|
||||
{genre.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
<table className="w-full text-sm">
|
||||
@@ -150,6 +181,12 @@ export function ShowsPanel() {
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.genre')}
|
||||
sortKey="genre"
|
||||
sort={sort}
|
||||
onToggle={sortColumn}
|
||||
/>
|
||||
<SortHeader
|
||||
label={t('admin.shows.seasons')}
|
||||
sortKey="seasons"
|
||||
@@ -168,7 +205,7 @@ export function ShowsPanel() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
|
||||
<td className="px-4 py-3 text-muted-foreground" colSpan={7}>
|
||||
{t('common.loading')}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -190,6 +227,7 @@ export function ShowsPanel() {
|
||||
<td className="px-4 py-2">
|
||||
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
|
||||
<td className="px-4 py-2">
|
||||
|
||||
@@ -9,8 +9,9 @@ import type {
|
||||
ShowSummaryDto,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export function listShows() {
|
||||
return apiRequest<ShowSummaryDto[]>('/admin/shows')
|
||||
export function listShows(genreId?: string) {
|
||||
const query = genreId ? `?${new URLSearchParams({ genreId }).toString()}` : ''
|
||||
return apiRequest<ShowSummaryDto[]>(`/admin/shows${query}`)
|
||||
}
|
||||
|
||||
export function getShow(id: string) {
|
||||
@@ -31,6 +32,14 @@ export function setShowAudience(id: string, audience: ShowAudience) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
|
||||
}
|
||||
|
||||
/** Полностью заменяет набор жанров шоу; основной — primaryGenreId (иначе первый в списке). */
|
||||
export function setShowGenres(id: string, genreIds: string[], primaryGenreId: string | null) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/genres`, {
|
||||
method: 'PUT',
|
||||
body: { genreIds, primaryGenreId },
|
||||
})
|
||||
}
|
||||
|
||||
export function renameShow(id: string, name: string) {
|
||||
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user