Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
tests / backend-tests (push) Canceled after 0s
sonar / analyze (push) Successful in 4m39s

This commit is contained in:
Leonid Pershin
2026-07-26 20:43:38 +03:00
parent f36dbfa9cb
commit 205672b77d
77 changed files with 3292 additions and 3102 deletions
@@ -1,535 +1,194 @@
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 { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { cn } from '@/shared/lib/cn'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
copyTemplateTo,
createChannelTemplate,
createLayer,
createSlot,
deleteLayer,
getChannel,
getChannelTemplate,
getSchedule,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { JunctionsCard } from './components/JunctionsCard'
import { LayerApplicabilityDialog } from './components/LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './components/ScheduleGrid'
import { SchedulePreview } from './components/SchedulePreview'
import { RulesCard } from './components/RulesCard'
import { SettingsCard } from './components/SettingsCard'
import { TemplateIssues } from './components/TemplateIssues'
import { TemplatePreview } from './components/TemplatePreview'
import { ViewerCard } from './components/ViewerCard'
import { SlotInspector, type SlotDraft } from './components/SlotInspector'
import { toTime } from './lib/format'
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
type ChannelTab = (typeof TABS)[number]
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 [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [copyFromChannel, setCopyFromChannel] = useState('')
const [tab, setTab] = useState<ChannelTab>('settings')
const { data: channel, isLoading } = useQuery({
queryKey: ['admin', 'channels', channelId],
queryFn: () => getChannel(channelId),
})
const { data: template, error: templateError } = 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 { data: channels } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => {
setApplyOpen(false)
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,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: invalidate,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: invalidate,
onError,
})
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
const createTemplateMutation = useMutation({
mutationFn: () => createChannelTemplate(channelId),
onSuccess: invalidate,
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
onSuccess: (result) => {
setCopyFromChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(
t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }),
)
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: invalidate,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: invalidate,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
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={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button>
</div>
)}
{/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
<nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
{TABS.map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-2 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.tabs.${value}`)}
</button>
))}
</nav>
{tab === 'settings' && (
<SettingsCard
channel={channel}
readyAssets={ready?.items ?? []}
bare
onSaved={invalidate}
onError={onError}
/>
)}
{/* Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой. */}
{tab === 'grid' && !template && (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
<Button
size="sm"
disabled={createTemplateMutation.isPending}
onClick={() => createTemplateMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
</Button>
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
</div>
)}
{tab === 'grid' && template && (
<Card>
<CardContent>
<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}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки на другой канал: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyFromChannel}
onChange={(e) => setCopyFromChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickSourceChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyFromChannel || copyTemplateMutation.isPending}
onClick={() => {
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
copyTemplateMutation.mutate(copyFromChannel)
}}
>
{t('admin.channels.copyHere')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() =>
copyDayMutation.mutate({ from: copySource, to: copyTargets })
}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
channelId={channelId}
draft={draft}
onClose={() => setDraft(null)}
onChanged={invalidate}
/>
)}
</div>
</div>
</CardContent>
</Card>
)}
{tab === 'rules' &&
(template ? (
<RulesCard template={template} bare onChanged={invalidate} onError={onError} />
) : (
<p className="text-sm text-muted-foreground">{t('admin.channels.noTemplate')}</p>
))}
{tab === 'junctions' && (
<JunctionsCard
channel={channel}
template={template}
bare
onChanged={invalidate}
onError={onError}
/>
)}
{tab === 'bumpers' && (
<BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
{tab === 'viewer' && (
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
{tab === 'air' && (
<Card>
<CardContent>
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
</CardContent>
</Card>
)}
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={invalidate}
onError={onError}
/>
)}
{applyOpen && (
<ApplyDialog
channelId={channelId}
utcOffsetMinutes={channel.utcOffsetMinutes}
pending={applyMutation.isPending}
onApply={() => applyMutation.mutate()}
onClose={() => setApplyOpen(false)}
/>
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
utcOffsetMinutes={channel.utcOffsetMinutes}
onClose={() => setTraceEntryId(null)}
/>
)}
</div>
)
}
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, Send } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listAllMedia } from '@/features/admin/media/api'
import { qk } from '@/shared/api/query-keys'
import { cn } from '@/shared/lib/cn'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { toast } from '@/shared/ui/toast-store'
import {
applyChannelTemplate,
getChannel,
getChannelTemplate,
getSchedule,
} from './api'
import { ApplyDialog } from './components/ApplyDialog'
import { BumperCard } from './components/BumperCard'
import { EntryTraceDialog } from './components/EntryTraceDialog'
import { GridTab } from './components/GridTab'
import { JunctionsCard } from './components/JunctionsCard'
import { RulesCard } from './components/RulesCard'
import { SchedulePreview } from './components/SchedulePreview'
import { SettingsCard } from './components/SettingsCard'
import { ViewerCard } from './components/ViewerCard'
/** Вкладки экрана канала: настройки первыми — с них канал и начинается. */
const TABS = ['settings', 'grid', 'rules', 'junctions', 'bumpers', 'viewer', 'air'] as const
type ChannelTab = (typeof TABS)[number]
export function ChannelDetail({ channelId }: { channelId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [applyOpen, setApplyOpen] = useState(false)
const [traceEntryId, setTraceEntryId] = useState<string | null>(null)
const [tab, setTab] = useState<ChannelTab>('settings')
const { data: channel, isLoading } = useQuery({
queryKey: qk.channels.detail(channelId),
queryFn: () => getChannel(channelId),
})
const { data: template, error: templateError } = useQuery({
queryKey: qk.channels.template(channelId),
queryFn: () => getChannelTemplate(channelId),
})
const { data: ready } = useQuery({
queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
const { data: schedule } = useQuery({
queryKey: qk.channels.schedule(channelId),
queryFn: () => getSchedule(channelId, new Date(), new Date(Date.now() + 12 * 3600_000)),
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: qk.channels.detail(channelId) })
}
const onError = useApiError()
const applyMutation = useMutation({
mutationFn: () => applyChannelTemplate(channelId),
onSuccess: (result) => {
setApplyOpen(false)
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,
})
if (isLoading || !channel) return <p className="text-muted-foreground">{t('common.loading')}</p>
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={() => setApplyOpen(true)}>
<Send className="h-4 w-4" /> {t('admin.channels.apply')}
</Button>
</div>
)}
{/* Вкладки вместо колонки карточек: экран канала перестал помещаться в один свиток. */}
<nav className="flex flex-wrap gap-4 border-b border-border text-xs uppercase tracking-wide">
{TABS.map((value) => (
<button
key={value}
type="button"
onClick={() => setTab(value)}
className={cn(
'pb-2 text-muted-foreground hover:text-foreground',
tab === value && 'border-b-2 border-primary text-primary',
)}
>
{t(`admin.channels.tabs.${value}`)}
</button>
))}
</nav>
{tab === 'settings' && (
<SettingsCard
channel={channel}
readyAssets={ready?.items ?? []}
bare
onSaved={invalidate}
onError={onError}
/>
)}
{tab === 'grid' && (
<GridTab
channelId={channelId}
template={template}
templateError={templateError}
onChanged={invalidate}
onError={onError}
/>
)}
{tab === 'rules' &&
(template ? (
<RulesCard template={template} bare onChanged={invalidate} onError={onError} />
) : (
<p className="text-sm text-muted-foreground">{t('admin.channels.noTemplate')}</p>
))}
{tab === 'junctions' && (
<JunctionsCard
channel={channel}
template={template}
bare
onChanged={invalidate}
onError={onError}
/>
)}
{tab === 'bumpers' && (
<BumperCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
{tab === 'viewer' && (
<ViewerCard channel={channel} bare onSaved={invalidate} onError={onError} />
)}
{tab === 'air' && (
<Card>
<CardContent>
<SchedulePreview entries={schedule ?? []} onShowTrace={setTraceEntryId} />
</CardContent>
</Card>
)}
{applyOpen && (
<ApplyDialog
channelId={channelId}
utcOffsetMinutes={channel.utcOffsetMinutes}
pending={applyMutation.isPending}
onApply={() => applyMutation.mutate()}
onClose={() => setApplyOpen(false)}
/>
)}
{traceEntryId && (
<EntryTraceDialog
entryId={traceEntryId}
utcOffsetMinutes={channel.utcOffsetMinutes}
onClose={() => setTraceEntryId(null)}
/>
)}
</div>
)
}
@@ -2,11 +2,11 @@ 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 { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { createChannel, listChannels } from './api'
function slugify(value: string) {
@@ -22,10 +22,9 @@ export function ChannelsPanel() {
const [name, setName] = useState('')
const [slug, setSlug] = useState('')
const { data, isLoading } = useQuery({ queryKey: ['admin', 'channels'], queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'channels'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const { data, isLoading } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.channels.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () => createChannel({ name: name.trim(), slug: slug || slugify(name) }),
+3 -3
View File
@@ -36,7 +36,7 @@ export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
export type ChannelSettingsBody = {
type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
@@ -230,7 +230,7 @@ export function reorderJunction(junctionId: string, elementIdsInOrder: string[])
})
}
export type BumperTemplateStyleBody = {
type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
@@ -297,7 +297,7 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export type BumperVariantBody = {
type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import {
Dialog,
@@ -33,7 +34,7 @@ export function ApplyDialog({
}) {
const { t } = useTranslation()
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'diff'],
queryKey: qk.channels.diff(channelId),
queryFn: () => getApplyDiff(channelId),
staleTime: 0,
gcTime: 0,
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'
import { qk } from '@/shared/api/query-keys'
import { useTranslation } from 'react-i18next'
import {
Dialog,
@@ -24,7 +25,7 @@ export function EntryTraceDialog({
}) {
const { t } = useTranslation()
const { data } = useQuery({
queryKey: ['admin', 'entries', entryId, 'trace'],
queryKey: qk.entries.trace(entryId),
queryFn: () => getEntryTrace(entryId),
})
@@ -0,0 +1,383 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Plus } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { GridLayerDto, ScheduleTemplateDto, SlotDto } from '@/shared/api/types'
import { Button } from '@/shared/ui/button'
import { Card, CardContent } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import {
copyTemplateTo,
createChannelTemplate,
createLayer,
createSlot,
deleteLayer,
listChannels,
toSlotBody,
updateLayer,
updateSlot,
} from '../api'
import { toTime } from '../lib/format'
import { LayerApplicabilityDialog } from './LayerApplicabilityDialog'
import { LayerList, ScheduleGrid } from './ScheduleGrid'
import { SlotInspector, type SlotDraft } from './SlotInspector'
import { TemplateIssues } from './TemplateIssues'
import { TemplatePreview } from './TemplatePreview'
/**
* Вкладка «Сетка»: слои, слоты и всё, что их правит. Вынесена из экрана канала целиком со своим
* состоянием — остальным вкладкам ни черновик слота, ни выбранный день копирования не нужны, а
* держать их в родителе значило перерисовывать весь экран на каждое движение мыши по сетке.
*/
export function GridTab({
channelId,
template,
templateError,
onChanged,
onError,
}: {
channelId: string
template: ScheduleTemplateDto | undefined
templateError: unknown
onChanged: () => void
onError: (error: unknown) => void
}) {
const { t } = useTranslation()
const [draft, setDraft] = useState<SlotDraft | null>(null)
const [activeLayerId, setActiveLayerId] = useState<string | null>(null)
const [viewDate, setViewDate] = useState<string>('')
const [applicabilityLayer, setApplicabilityLayer] = useState<GridLayerDto | null>(null)
// День, который копируем, и отмеченные дни-приёмники.
const [copySource, setCopySource] = useState<number | null>(null)
const [copyTargets, setCopyTargets] = useState<number[]>([])
const [copyFromChannel, setCopyFromChannel] = useState('')
const { data: channels } = useQuery({ queryKey: qk.channels.all, queryFn: listChannels })
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: onChanged,
onError,
})
const deleteLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) => deleteLayer(layer.id),
onSuccess: onChanged,
onError,
})
const toggleLayerMutation = useMutation({
mutationFn: (layer: GridLayerDto) =>
updateLayer(layer.id, {
name: layer.name,
priority: layer.priority,
applicability: layer.applicability,
isEnabled: !layer.isEnabled,
}),
onSuccess: onChanged,
onError,
})
/**
* Порядок слоёв задаётся перетаскиванием, а хранится приоритетом. Раздаём приоритеты с шагом 10
* снизу вверх: шаг оставляет место, чтобы следующая вставка не переписывала весь список.
*/
const reorderLayersMutation = useMutation({
mutationFn: async (layerIdsTopFirst: string[]) => {
const byId = new Map(template!.layers.map((l) => [l.id, l]))
const total = layerIdsTopFirst.length
await Promise.all(
layerIdsTopFirst.map((id, index) => {
const layer = byId.get(id)
const priority = (total - index) * 10
if (!layer || layer.priority === priority) return Promise.resolve()
return updateLayer(id, {
name: layer.name,
priority,
applicability: layer.applicability,
isEnabled: layer.isEnabled,
})
}),
)
},
onSuccess: onChanged,
onError,
})
// Канал без сетки — наследство старой ротации: заводим шаблон на месте, а не пересоздаём канал.
const createTemplateMutation = useMutation({
mutationFn: () => createChannelTemplate(channelId),
onSuccess: onChanged,
onError,
})
const copyTemplateMutation = useMutation({
mutationFn: (sourceChannelId: string) => copyTemplateTo(sourceChannelId, channelId),
onSuccess: (result) => {
setCopyFromChannel('')
toast.success(
t('admin.channels.templateCopied', { layers: result.layers, slots: result.slots }),
)
if (result.droppedBumperRefs > 0)
toast.error(t('admin.channels.copyDroppedBumpers', { count: result.droppedBumperRefs }))
},
onError,
})
const moveSlotMutation = useMutation({
mutationFn: ({
slot,
weekday,
startMinutes,
}: {
slot: SlotDto
weekday: number
startMinutes: number
}) => updateSlot(slot.id, { ...toSlotBody(slot), weekday, targetStart: toTime(startMinutes) }),
onSuccess: onChanged,
onError,
})
const resizeSlotMutation = useMutation({
mutationFn: ({ slot, minutes }: { slot: SlotDto; minutes: number }) =>
updateSlot(slot.id, { ...toSlotBody(slot), targetDurationMinutes: minutes }),
onSuccess: onChanged,
onError,
})
/** Копирование дня: слоты «каждый день» не копируются — они и так есть во всех колонках. */
const copyDayMutation = useMutation({
mutationFn: async ({ from, to }: { from: number; to: number[] }) => {
const sources = (template?.layers ?? []).flatMap((layer) =>
layer.slots.filter((slot) => slot.weekday === from).map((slot) => ({ layer, slot })),
)
for (const weekday of to)
for (const { layer, slot } of sources)
await createSlot(layer.id, { ...toSlotBody(slot), weekday })
},
onSuccess: () => {
setCopySource(null)
onChanged()
},
onError,
})
// Шаблон мог не загрузиться — раньше вкладка сетки просто оказывалась пустой.
if (!template)
return (
<div className="flex flex-col items-start gap-3">
<p className="text-sm text-muted-foreground">
{templateError instanceof HttpError
? templateError.detail
: t('admin.channels.noTemplate')}
</p>
<Button
size="sm"
disabled={createTemplateMutation.isPending}
onClick={() => createTemplateMutation.mutate()}
>
<Plus className="h-4 w-4" /> {t('admin.channels.createTemplate')}
</Button>
<p className="text-xs text-muted-foreground">{t('admin.channels.createTemplateHint')}</p>
</div>
)
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 (
<>
<Card>
<CardContent>
<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}
viewDate={viewDate || null}
onSelect={(layer) => setActiveLayerId(layer.id)}
onDelete={(layer) => deleteLayerMutation.mutate(layer)}
onToggle={(layer) => toggleLayerMutation.mutate(layer)}
onReorder={(order) => reorderLayersMutation.mutate(order)}
onEditApplicability={setApplicabilityLayer}
/>
<p className="text-xs text-muted-foreground">{t('admin.channels.layersHint')}</p>
{/* Копия сетки с другого канала: группы общие, поэтому переносятся только правила. */}
<div className="flex flex-col gap-1.5 border-t border-border pt-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.channels.copyTemplate')}
</span>
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={copyFromChannel}
onChange={(e) => setCopyFromChannel(e.target.value)}
>
<option value="">{t('admin.channels.pickSourceChannel')}</option>
{(channels ?? [])
.filter((c) => c.id !== channelId)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
size="sm"
variant="outline"
disabled={!copyFromChannel || copyTemplateMutation.isPending}
onClick={() => {
// Замена своей сетки — необратимая правка, поэтому спрашиваем перед ней.
if (!window.confirm(t('admin.channels.copyTemplateConfirm'))) return
copyTemplateMutation.mutate(copyFromChannel)
}}
>
{t('admin.channels.copyHere')}
</Button>
<p className="text-xs text-muted-foreground">
{t('admin.channels.copyTemplateHint')}
</p>
</div>
</div>
<div className="flex flex-col gap-3">
<TemplateIssues
channelId={channelId}
slotsById={
new Map(template.layers.flatMap((l) => l.slots).map((slot) => [slot.id, slot]))
}
onGoToSlot={openSlot}
/>
<TemplatePreview channelId={channelId} />
{/* Сетка на конкретную дату: видно, какие слои в этот день действительно действуют. */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('admin.channels.showForDate')}</span>
<Input
type="date"
className="h-8 w-40"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
/>
{viewDate && (
<Button size="sm" variant="ghost" onClick={() => setViewDate('')}>
{t('admin.channels.allDates')}
</Button>
)}
</div>
{/* Копирование дня: сначала выбирается источник, потом дни-приёмники. */}
{copySource !== null && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
<span>
{t('admin.channels.copyDayFrom', {
day: t(`admin.channels.weekdays.${copySource}`),
})}
</span>
{[1, 2, 3, 4, 5, 6, 0]
.filter((day) => day !== copySource)
.map((day) => (
<label key={day} className="flex items-center gap-1">
<input
type="checkbox"
checked={copyTargets.includes(day)}
onChange={(e) =>
setCopyTargets((current) =>
e.target.checked
? [...current, day]
: current.filter((d) => d !== day),
)
}
/>
{t(`admin.channels.weekdays.${day}`)}
</label>
))}
<Button
size="sm"
disabled={copyTargets.length === 0 || copyDayMutation.isPending}
onClick={() => copyDayMutation.mutate({ from: copySource, to: copyTargets })}
>
{t('admin.channels.copy')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setCopySource(null)}>
{t('common.cancel')}
</Button>
</div>
)}
<ScheduleGrid
template={template}
selectedSlotId={draft?.slot?.id ?? null}
viewDate={viewDate || null}
onSelectSlot={openSlot}
onAddSlot={openNewSlot}
onMoveSlot={(slot, weekday, startMinutes) =>
moveSlotMutation.mutate({ slot, weekday, startMinutes })
}
onResizeSlot={(slot, minutes) => resizeSlotMutation.mutate({ slot, minutes })}
onCopyDay={(weekday) => {
setCopySource(weekday)
setCopyTargets([])
}}
/>
{draft && (
<SlotInspector
channelId={channelId}
draft={draft}
onClose={() => setDraft(null)}
onChanged={onChanged}
/>
)}
</div>
</div>
</CardContent>
</Card>
{applicabilityLayer && (
<LayerApplicabilityDialog
layer={applicabilityLayer}
onClose={() => setApplicabilityLayer(null)}
onChanged={onChanged}
onError={onError}
/>
)}
</>
)
}
@@ -8,6 +8,7 @@ import type {
JunctionElementDto,
JunctionElementKind,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import {
Dialog,
@@ -53,7 +54,7 @@ export function JunctionElementDialog({
}) {
const { t } = useTranslation()
const [body, setBody] = useState<JunctionElementBody>(() => toBody(element))
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const patch = (part: Partial<JunctionElementBody>) => setBody((prev) => ({ ...prev, ...part }))
@@ -12,6 +12,7 @@ import type {
JunctionTemplateDto,
ScheduleTemplateDto,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
@@ -79,10 +80,10 @@ export function JunctionsCard({
const [newName, setNewName] = useState('')
const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channel.id, 'junctions'],
queryKey: qk.channels.junctions(channel.id),
queryFn: () => listJunctions(channel.id),
})
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const createMutation = useMutation({
mutationFn: () => createJunction(channel.id, newName.trim()),
@@ -3,7 +3,6 @@ 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,
@@ -12,10 +11,11 @@ import type {
SlotKind,
SlotStrategyType,
} from '@/shared/api/types'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
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,
@@ -83,14 +83,13 @@ export function SlotInspector({
setBody(draft.slot ? toSlotBody(draft.slot) : emptyBody(draft.defaults))
}, [draft])
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const { data: junctions } = useQuery({
queryKey: ['admin', 'channels', channelId, 'junctions'],
queryKey: qk.channels.junctions(channelId),
queryFn: () => listJunctions(channelId),
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const save = useMutation({
mutationFn: async () => {
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, CircleAlert } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { SlotDto, TemplateIssueDto } from '@/shared/api/types'
import { cn } from '@/shared/lib/cn'
import { getTemplateIssues } from '../api'
@@ -20,7 +21,7 @@ export function TemplateIssues({
}) {
const { t } = useTranslation()
const { data: issues } = useQuery({
queryKey: ['admin', 'channels', channelId, 'issues'],
queryKey: qk.channels.issues(channelId),
queryFn: () => getTemplateIssues(channelId),
})
@@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'
import { Eye } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { PlannedItemKind, PreviewItemDto, SchedulePreviewDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
@@ -33,7 +34,7 @@ export function TemplatePreview({ channelId }: { channelId: string }) {
const [tab, setTab] = useState<'programme' | 'tape' | 'problems'>('programme')
const { data, isFetching } = useQuery({
queryKey: ['admin', 'channels', channelId, 'preview', days],
queryKey: qk.channels.preview(channelId, days),
queryFn: () => previewTemplate(channelId, days),
enabled: open,
// Черновик правил может меняться между открытиями — кэшировать прогон смысла нет.
@@ -1,38 +0,0 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
export function NumberField({
label,
value,
onChange,
min,
}: {
label: string
value: number
onChange: (v: number) => void
min?: number
}) {
return (
<div className="flex flex-col gap-1.5">
<Label>{label}</Label>
<Input
type="number"
min={min}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-24"
/>
</div>
)
}
export function RemoveButton({ onClick }: { onClick: () => void }) {
const { t } = useTranslation()
return (
<Button size="sm" variant="destructive" onClick={onClick}>
{t('common.delete')}
</Button>
)
}
@@ -16,14 +16,6 @@ export function toTime(minutes: number): string {
return `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}:00`
}
/** Минуты суток → «HH:MM». */
export function formatMinute(minute: number | null) {
if (minute == null) return '—'
const h = Math.floor(minute / 60)
const m = minute % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
/**
* Момент UTC во времени канала. Сетка задаётся в нём, поэтому предпросмотр показывается так же:
* локальное время админа тут только запутало бы.
@@ -6,13 +6,13 @@ 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 { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
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,
@@ -32,16 +32,15 @@ export function CollectionDetail({ collectionId }: { collectionId: string }) {
const [description, setDescription] = useState<string | null>(null)
const { data: collection, isLoading } = useQuery({
queryKey: ['admin', 'collections', collectionId],
queryKey: qk.collections.detail(collectionId),
queryFn: () => getCollection(collectionId),
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const saveMutation = useMutation({
mutationFn: () =>
@@ -3,11 +3,11 @@ 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 { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
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() {
@@ -17,13 +17,12 @@ export function CollectionsPanel() {
const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'collections'],
queryKey: qk.collections.all,
queryFn: listCollections,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.collections.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () => createCollection({ name: name.trim() }),
@@ -5,8 +5,9 @@ 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 { qk } from '@/shared/api/query-keys'
import type { GenreDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -19,7 +20,6 @@ import {
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({
@@ -53,7 +53,7 @@ export function GenresPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: genres, isLoading } = useQuery({
queryKey: ['admin', 'genres'],
queryKey: qk.genres.all,
queryFn: listGenres,
})
const { sort, toggle } = useTableSort('sortOrder', false)
@@ -64,9 +64,8 @@ export function GenresPanel() {
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 invalidate = () => queryClient.invalidateQueries({ queryKey: qk.genres.all })
const reportError = useApiError()
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
const updateMutation = useMutation({
@@ -3,8 +3,9 @@ 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 { qk } from '@/shared/api/query-keys'
import type { GroupCandidateDto, GroupFilter } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -36,7 +37,7 @@ export function GroupDetail({ groupId }: { groupId: string }) {
const [dragged, setDragged] = useState<string | null>(null)
const { data: group, isLoading } = useQuery({
queryKey: ['admin', 'groups', groupId],
queryKey: qk.groups.detail(groupId),
queryFn: () => getGroup(groupId),
})
@@ -46,10 +47,9 @@ export function GroupDetail({ groupId }: { groupId: string }) {
}, [group, filter])
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const saveMutation = useMutation({
mutationFn: () =>
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api'
import { qk } from '@/shared/api/query-keys'
import type { GroupElementKind, GroupFilter, ShowAudience, ShowKind } from '@/shared/api/types'
import { SHOW_AUDIENCES } from '@/shared/api/types'
import { Input } from '@/shared/ui/input'
@@ -18,7 +19,7 @@ export function GroupFilterPanel({
onChange: (next: GroupFilter) => void
}) {
const { t } = useTranslation()
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const patch = (part: Partial<GroupFilter>) => onChange({ ...filter, ...part })
@@ -2,12 +2,12 @@ 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 { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
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'
@@ -17,11 +17,10 @@ export function GroupsPanel() {
const [name, setName] = useState('')
const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.groups.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () => createGroup({ name: name.trim() }),
@@ -2,19 +2,19 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Trash2, Upload } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { ImageCategory } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
type ImageOrder = 'new' | 'old' | 'az' | 'za'
export type ImagePick = { id: string; url: string }
type ImagePick = { id: string; url: string }
/**
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
@@ -35,13 +35,12 @@ export function GalleryBrowser({
const [active, setActive] = useState<ImageCategory>(category)
const fileInput = useRef<HTMLInputElement>(null)
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const [order, setOrder] = useState<ImageOrder>('new')
const { data: images, isLoading } = useQuery({
queryKey: ['admin', 'images', active],
queryKey: qk.images.byCategory(active),
queryFn: () => listImages(active),
})
@@ -62,7 +61,7 @@ export function GalleryBrowser({
return arr
}, [images, order])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.images.byCategory(active) })
const pick = (id: string) => {
if (!onSelect) return
@@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addGroupElements, createGroup, listGroups } from '@/features/admin/groups/api'
import { qk } from '@/shared/api/query-keys'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@@ -21,9 +22,9 @@ export function ClipGroupPanel({ onError }: { onError: (error: unknown) => void
const [newName, setNewName] = useState('')
const [over, setOver] = useState(false)
const { data: groups } = useQuery({ queryKey: ['admin', 'groups'], queryFn: listGroups })
const { data: groups } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'groups'] })
void queryClient.invalidateQueries({ queryKey: qk.groups.all })
}
const createMutation = useMutation({
@@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next'
import { deleteCollection } from '@/features/admin/collections/api'
import { useUploadStore } from '@/features/admin/media/upload-store'
import { deleteShow, renameShow } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { InterstitialDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { BlockBuilder } from './BlockBuilder'
import { ClipGroupPanel } from './ClipGroupPanel'
import { listInterstitialBlocks, listInterstitials, mediaPreviewUrl } from './api'
@@ -34,19 +34,18 @@ export function InterstitialsPanel() {
const [renaming, setRenaming] = useState<{ id: string; name: string } | null>(null)
const { data: clips, isLoading } = useQuery({
queryKey: ['admin', 'interstitials'],
queryKey: qk.interstitials.all,
queryFn: listInterstitials,
})
const { data: blocks } = useQuery({
queryKey: ['admin', 'interstitials', 'blocks'],
queryKey: qk.interstitials.blocks,
queryFn: listInterstitialBlocks,
})
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => renameShow(id, name),
@@ -2,7 +2,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
@@ -15,14 +16,13 @@ export function MaintenancePanel() {
const queryClient = useQueryClient()
const [showId, setShowId] = useState('')
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
// Затрагиваются медиа/шоу/каналы — сбрасываем все связанные кэши.
const invalidateAll = () => {
for (const key of [['admin', 'media'], ['admin', 'shows'], ['admin', 'channels']])
for (const key of [qk.media.all, qk.shows.all, qk.channels.all])
void queryClient.invalidateQueries({ queryKey: key })
}
@@ -3,8 +3,9 @@ import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -57,10 +58,10 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'],
queryKey: qk.media.manual,
queryFn: listManualInbox,
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
@@ -146,6 +147,8 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
(f) => parsedByPath.get(f.relativePath)?.episode != null,
).length
const onError = useApiError()
const importMutation = useMutation({
mutationFn: () =>
importManualInbox(
@@ -167,12 +170,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
toast.error(`${failure.relativePath}: ${failure.reason}`)
setSelected([])
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (result.failed.length === 0) onClose()
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
onError,
})
const toggle = (path: string) =>
@@ -2,14 +2,14 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { ManualInboxDialog } from './ManualInboxDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
@@ -63,7 +63,7 @@ export function MediaPanel() {
}
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'media', filter, page, sort.key, sort.desc],
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
queryFn: () =>
listMedia({
page,
@@ -80,7 +80,7 @@ export function MediaPanel() {
})
const { data: stats } = useQuery({
queryKey: ['admin', 'media', 'stats'],
queryKey: qk.media.stats,
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
@@ -101,9 +101,8 @@ export function MediaPanel() {
void refetch()
}, [activity, refetch])
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError()
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -30,7 +31,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: qk.shows.all, queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
+1 -1
View File
@@ -10,7 +10,7 @@ import type {
PagedList,
} from '@/shared/api/types'
export type ListMediaParams = {
type ListMediaParams = {
page: number
pageSize: number
statuses?: MediaAssetStatus[]
@@ -1,4 +1,4 @@
export type ParseOptions = {
type ParseOptions = {
/** Ручной сезон — перебивает распознанный/дефолтный. */
seasonOverride?: number | null
/** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */
@@ -5,7 +5,7 @@
* («Star Trek Discovery» важнее «Star Trek»).
*/
export type ShowNameRef = { id: string; name: string; originalName?: string | null }
type ShowNameRef = { id: string; name: string; originalName?: string | null }
/** Приводит строку к «словам через пробел»: буквы/цифры сохраняем, всё прочее — разделитель. */
function normalize(value: string): string {
@@ -1,4 +1,5 @@
import { create } from 'zustand'
import { qk } from '@/shared/api/query-keys'
import { HttpError, refreshAccessToken } from '@/shared/api/client'
import { queryClient } from '@/shared/api/query-client'
import { importInterstitials } from '@/features/admin/interstitials/api'
@@ -31,7 +32,7 @@ type UploadStore = {
* <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
* автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
*/
export type EnqueueOptions = {
type EnqueueOptions = {
showId?: string
resolveShowId?: (file: File) => string | undefined
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
@@ -105,13 +106,13 @@ async function pump() {
if (created) {
patch(job.id, { status: 'done', percent: 100 })
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
void queryClient.invalidateQueries({ queryKey: qk.media.all })
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
if (job.showId) {
try {
await addEpisode(job.showId, created.id)
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
@@ -119,7 +120,7 @@ async function pump() {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try {
await importInterstitials([created.id])
void queryClient.invalidateQueries({ queryKey: ['admin', 'interstitials'] })
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
} catch {
toast.error(`${job.file.name}: не удалось завести ролик`)
}
@@ -5,7 +5,8 @@ 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 { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -19,7 +20,6 @@ import {
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 { createRole, deleteRole, listRoles, updateRole } from './api'
const schema = z.object({ name: z.string().min(1).max(64) })
@@ -27,14 +27,15 @@ const schema = z.object({ name: z.string().min(1).max(64) })
export function RolesPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: roles, isLoading } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
const { data: roles, isLoading } = useQuery({ queryKey: qk.roles.all, queryFn: listRoles })
const { sort, toggle } = useTableSort('name', false)
const sortedRoles = sortRows(roles ?? [], sort, {
name: (r) => r.name.toLowerCase(),
system: (r) => r.isSystem,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'roles'] })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.roles.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: (name: string) => createRole(name),
@@ -44,17 +45,13 @@ export function RolesPanel() {
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRole(id),
onSuccess: invalidate,
onError: (error) => {
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
onError,
})
const renameMutation = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => updateRole(id, name),
onSuccess: invalidate,
onError: (error) => {
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
},
onError,
})
const [open, setOpen] = useState(false)
@@ -66,7 +63,7 @@ export function RolesPanel() {
reset()
setOpen(false)
} catch (error) {
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
onError(error)
}
}
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card'
import { Input } from '@/shared/ui/input'
@@ -16,7 +17,7 @@ export function SettingsPanel() {
const [channelNumbersEnabled, setChannelNumbersEnabled] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'settings'],
queryKey: qk.settings.all,
queryFn: getSiteSettings,
})
@@ -28,6 +29,8 @@ export function SettingsPanel() {
}
}, [data])
const onError = useApiError()
const save = useMutation({
mutationFn: () =>
updateSiteSettings({
@@ -37,10 +40,9 @@ export function SettingsPanel() {
}),
onSuccess: () => {
toast.success(t('settings.saved'))
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] })
void queryClient.invalidateQueries({ queryKey: qk.settings.all })
},
onError: (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
onError,
})
return (
@@ -3,8 +3,9 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -38,17 +39,16 @@ export function ShowDetail({ showId }: { showId: string }) {
const [epPage, setEpPage] = useState(1)
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
queryKey: qk.shows.detail(showId),
queryFn: () => getShow(showId),
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready', 'all'],
queryKey: qk.media.ready,
queryFn: () => listAllMedia({ statuses: ['Ready'] }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = useApiError()
const audienceMutation = useMutation({
mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience),
@@ -3,12 +3,12 @@ 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 { qk } from '@/shared/api/query-keys'
import type { ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
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'
/**
@@ -22,19 +22,20 @@ export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged:
const [primary, setPrimary] = useState<string | null>(null)
const { data: genres } = useQuery({
queryKey: ['admin', 'genres'],
queryKey: qk.genres.all,
queryFn: listGenres,
enabled: open,
})
const onError = useApiError()
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')),
onError,
})
const openDialog = () => {
@@ -2,8 +2,9 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import type { MetadataCandidate, MissingEpisodesReport, ShowDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
@@ -46,12 +47,11 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
}, [show.description, show.year])
const { data: providers } = useQuery({
queryKey: ['admin', 'metadata', 'providers'],
queryKey: qk.metadata.providers,
queryFn: getMetadataProviders,
})
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const changed = () => onChanged()
const setPoster = useMutation({
@@ -2,15 +2,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
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'
@@ -33,9 +33,9 @@ export function ShowsPanel() {
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
const [genreFilter, setGenreFilter] = useState('all')
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const { data, isLoading } = useQuery({
queryKey: ['admin', 'shows', { genreId: genreFilter }],
queryKey: qk.shows.byGenre(genreFilter),
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
})
@@ -60,9 +60,8 @@ export function ShowsPanel() {
}, [data, query, sort])
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const pageItems = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.all })
const onError = useApiError()
const createMutation = useMutation({
mutationFn: () =>
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/shared/ui/card'
@@ -40,9 +41,9 @@ export function UsersPanel() {
const [newRoleId, setNewRoleId] = useState('')
const [resetTarget, setResetTarget] = useState<UserSummaryDto | null>(null)
const { data: roles } = useQuery({ queryKey: ['admin', 'roles'], queryFn: listRoles })
const { data: roles } = useQuery({ queryKey: qk.roles.all, queryFn: listRoles })
const { data, isLoading } = useQuery({
queryKey: ['admin', 'users', page, search, roleId, sort.key, sort.desc],
queryKey: qk.users.list(page, search, roleId, sort.key, sort.desc),
queryFn: () =>
listUsers({
page,
@@ -54,9 +55,9 @@ export function UsersPanel() {
}),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.users.all })
const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const onError = useApiError()
const blockMutation = useMutation({ mutationFn: blockUser, onSuccess: invalidate, onError })
const unblockMutation = useMutation({ mutationFn: unblockUser, onSuccess: invalidate, onError })
+1 -1
View File
@@ -1,7 +1,7 @@
import { apiRequest } from '@/shared/api/client'
import type { CreatedIdResponse, PagedList, UserSummaryDto } from '@/shared/api/types'
export type ListUsersParams = {
type ListUsersParams = {
page: number
pageSize: number
search?: string