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:
@@ -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