Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
@@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, ShowAudience } from '@/shared/api/types'
import { SHOW_AUDIENCES, type MediaAssetDto, type ShowAudience } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -19,6 +19,7 @@ import {
parseEpisodeName,
} from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard'
import { imageUrl } from '@/features/admin/images/api'
import { addEpisode, getShow, removeEpisode, setShowAudience } from './api'
@@ -156,9 +157,11 @@ export function ShowDetail({ showId }: { showId: string }) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
{SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{show.kind === 'Series' && (
@@ -175,6 +178,24 @@ export function ShowDetail({ showId }: { showId: string }) {
{t('admin.shows.loadedSeasons')}: {seasons.join(', ')}
</p>
)}
<div className="mt-2">
<ShowGenresField show={show} onChanged={invalidate} />
</div>
{show.collections.length > 0 && (
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{t('admin.shows.inCollections')}:
{show.collections.map((collection) => (
<Link
key={collection.id}
to="/admin/collections/$collectionId"
params={{ collectionId: collection.id }}
className="text-primary hover:underline"
>
{collection.name}
</Link>
))}
</p>
)}
</div>
<ShowMetadataCard show={show} onChanged={invalidate} />
@@ -0,0 +1,115 @@
import { useMutation, useQuery } from '@tanstack/react-query'
import { Tag } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api'
import { HttpError } from '@/shared/api/client'
import type { ShowDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { toast } from '@/shared/ui/toast-store'
import { setShowGenres } from './api'
/**
* Жанры шоу: бейджи в шапке карточки + диалог правки. Основной жанр отмечается отдельно —
* он показывается в списке шоу и участвует в отборе контента наравне с остальными.
*/
export function ShowGenresField({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<string[]>([])
const [primary, setPrimary] = useState<string | null>(null)
const { data: genres } = useQuery({
queryKey: ['admin', 'genres'],
queryFn: listGenres,
enabled: open,
})
const mutation = useMutation({
mutationFn: () => setShowGenres(show.id, selected, primary),
onSuccess: () => {
onChanged()
setOpen(false)
},
onError: (error) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
})
const openDialog = () => {
setSelected(show.genres.map((g) => g.id))
setPrimary(show.genres.find((g) => g.isPrimary)?.id ?? null)
setOpen(true)
}
const toggle = (id: string) => {
setSelected((prev) => {
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
// Снятый жанр не может остаться основным — иначе сервер молча выберет другой.
if (!next.includes(id) && primary === id) setPrimary(next[0] ?? null)
if (next.includes(id) && primary === null) setPrimary(id)
return next
})
}
return (
<>
<div className="flex flex-wrap items-center gap-2">
{show.genres.length === 0 ? (
<span className="text-sm text-muted-foreground">{t('admin.shows.genresEmpty')}</span>
) : (
show.genres.map((genre) => (
<Badge key={genre.id} variant={genre.isPrimary ? 'default' : 'muted'}>
{genre.name}
</Badge>
))
)}
<Button size="sm" variant="outline" onClick={openDialog}>
<Tag className="h-4 w-4" /> {t('admin.shows.genresEdit')}
</Button>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.shows.genresEdit')}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">{t('admin.shows.genresHint')}</p>
<div className="flex max-h-80 flex-col gap-1 overflow-y-auto">
{(genres ?? []).map((genre) => {
const checked = selected.includes(genre.id)
return (
<div
key={genre.id}
className="flex items-center justify-between gap-3 rounded px-2 py-1 hover:bg-muted/40"
>
<label className="flex flex-1 items-center gap-2 text-sm">
<input type="checkbox" checked={checked} onChange={() => toggle(genre.id)} />
{genre.name}
</label>
{checked && (
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="radio"
name="primaryGenre"
checked={primary === genre.id}
onChange={() => setPrimary(genre.id)}
/>
{t('admin.shows.genrePrimary')}
</label>
)}
</div>
)
})}
</div>
<DialogFooter>
<Button disabled={mutation.isPending} onClick={() => mutation.mutate()}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
@@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { HttpError } from '@/shared/api/client'
import type { ShowAudience, ShowKind } from '@/shared/api/types'
import { SHOW_AUDIENCES, type ShowAudience, type ShowKind } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
@@ -11,6 +11,7 @@ import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { listGenres } from '@/features/admin/genres/api'
import { createShow, deleteShow, listShows } from './api'
const PAGE_SIZE = 20
@@ -30,7 +31,13 @@ export function ShowsPanel() {
toggle(key)
}
const { data, isLoading } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
// Фильтр по жанру — серверный: в списке видно только основной жанр, а отбирать нужно и по остальным.
const [genreFilter, setGenreFilter] = useState('all')
const { data: genres } = useQuery({ queryKey: ['admin', 'genres'], queryFn: listGenres })
const { data, isLoading } = useQuery({
queryKey: ['admin', 'shows', { genreId: genreFilter }],
queryFn: () => listShows(genreFilter === 'all' ? undefined : genreFilter),
})
// Список шоу обычно умещается в одну загрузку — фильтруем, сортируем и листаем на клиенте.
const filtered = useMemo(() => {
@@ -46,6 +53,7 @@ export function ShowsPanel() {
name: (s) => s.name.toLowerCase(),
kind: (s) => s.kind,
audience: (s) => s.audience,
genre: (s) => (s.primaryGenre ?? '').toLowerCase(),
seasons: (s) => s.seasonCount,
episodes: (s) => s.episodeCount,
})
@@ -104,9 +112,11 @@ export function ShowsPanel() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="General">{t('admin.shows.audiences.General')}</SelectItem>
<SelectItem value="Kids">{t('admin.shows.audiences.Kids')}</SelectItem>
<SelectItem value="Adult">{t('admin.shows.audiences.Adult')}</SelectItem>
{SHOW_AUDIENCES.map((value) => (
<SelectItem key={value} value={value}>
{t(`admin.shows.audiences.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
@@ -118,15 +128,36 @@ export function ShowsPanel() {
</Button>
</div>
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => {
setPage(1)
setQuery(e.target.value)
}}
/>
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-xs"
placeholder={t('common.search')}
value={query}
onChange={(e) => {
setPage(1)
setQuery(e.target.value)
}}
/>
<Select
value={genreFilter}
onValueChange={(v) => {
setPage(1)
setGenreFilter(v)
}}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('admin.shows.allGenres')}</SelectItem>
{(genres ?? []).map((genre) => (
<SelectItem key={genre.id} value={genre.id}>
{genre.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
@@ -150,6 +181,12 @@ export function ShowsPanel() {
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.genre')}
sortKey="genre"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.shows.seasons')}
sortKey="seasons"
@@ -168,7 +205,7 @@ export function ShowsPanel() {
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
<td className="px-4 py-3 text-muted-foreground" colSpan={7}>
{t('common.loading')}
</td>
</tr>
@@ -190,6 +227,7 @@ export function ShowsPanel() {
<td className="px-4 py-2">
<Badge variant="muted">{t(`admin.shows.audiences.${show.audience}`)}</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{show.primaryGenre ?? '—'}</td>
<td className="px-4 py-2 text-muted-foreground">{show.seasonCount}</td>
<td className="px-4 py-2 text-muted-foreground">{show.episodeCount}</td>
<td className="px-4 py-2">
+11 -2
View File
@@ -9,8 +9,9 @@ import type {
ShowSummaryDto,
} from '@/shared/api/types'
export function listShows() {
return apiRequest<ShowSummaryDto[]>('/admin/shows')
export function listShows(genreId?: string) {
const query = genreId ? `?${new URLSearchParams({ genreId }).toString()}` : ''
return apiRequest<ShowSummaryDto[]>(`/admin/shows${query}`)
}
export function getShow(id: string) {
@@ -31,6 +32,14 @@ export function setShowAudience(id: string, audience: ShowAudience) {
return apiRequest<void>(`/admin/shows/${id}/audience`, { method: 'PUT', body: { audience } })
}
/** Полностью заменяет набор жанров шоу; основной — primaryGenreId (иначе первый в списке). */
export function setShowGenres(id: string, genreIds: string[], primaryGenreId: string | null) {
return apiRequest<void>(`/admin/shows/${id}/genres`, {
method: 'PUT',
body: { genreIds, primaryGenreId },
})
}
export function renameShow(id: string, name: string) {
return apiRequest<void>(`/admin/shows/${id}/name`, { method: 'PUT', body: { name } })
}