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
@@ -0,0 +1,233 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { ChevronLeft, GripVertical, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import {
addCollectionShow,
getCollection,
removeCollectionShow,
reorderCollection,
setCollectionPoster,
updateCollection,
} from './api'
export function CollectionDetail({ collectionId }: { collectionId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [galleryOpen, setGalleryOpen] = useState(false)
const [dragged, setDragged] = useState<string | null>(null)
const [pendingShow, setPendingShow] = useState('')
const [name, setName] = useState<string | null>(null)
const [description, setDescription] = useState<string | null>(null)
const { data: collection, isLoading } = useQuery({
queryKey: ['admin', 'collections', collectionId],
queryFn: () => getCollection(collectionId),
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
}
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const saveMutation = useMutation({
mutationFn: () =>
updateCollection(collectionId, {
name: (name ?? collection?.name ?? '').trim(),
description: description ?? collection?.description ?? null,
}),
onSuccess: invalidate,
onError,
})
const posterMutation = useMutation({
mutationFn: (imageId: string | null) => setCollectionPoster(collectionId, imageId),
onSuccess: invalidate,
onError,
})
const addMutation = useMutation({
mutationFn: (showId: string) => addCollectionShow(collectionId, showId),
onSuccess: () => {
setPendingShow('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (showId: string) => removeCollectionShow(collectionId, showId),
onSuccess: invalidate,
onError,
})
const reorderMutation = useMutation({
mutationFn: (order: string[]) => reorderCollection(collectionId, order),
onSuccess: invalidate,
onError,
})
if (isLoading || !collection)
return <p className="text-muted-foreground">{t('common.loading')}</p>
const memberIds = new Set(collection.items.map((i) => i.showId))
const available = (shows ?? []).filter((s) => !memberIds.has(s.id))
/** Порядок пересобирается целиком и уходит одним запросом — сервер сам расставит позиции. */
const dropOn = (targetShowId: string) => {
if (!dragged || dragged === targetShowId) return
const order = collection.items.map((i) => i.showId).filter((id) => id !== dragged)
const at = order.indexOf(targetShowId)
order.splice(at, 0, dragged)
setDragged(null)
reorderMutation.mutate(order)
}
return (
<div className="flex flex-col gap-6">
<div>
<Button asChild size="sm" variant="ghost">
<Link to="/admin/collections">
<ChevronLeft className="h-4 w-4" />
{t('admin.collections.title')}
</Link>
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{collection.posterImageId ? (
<img
src={imageUrl(collection.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
) : (
<span className="text-xs text-muted-foreground">{t('admin.metadata.noPoster')}</span>
)}
</div>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickPoster')}
</Button>
{collection.posterImageId && (
<Button size="sm" variant="ghost" onClick={() => posterMutation.mutate(null)}>
{t('common.delete')}
</Button>
)}
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => posterMutation.mutate(img.id)}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.name')}</Label>
<Input
value={name ?? collection.name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.description')}</Label>
<Input
value={description ?? collection.description ?? ''}
maxLength={2048}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div>
<Button size="sm" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('admin.collections.parts')}
</h3>
<Select value={pendingShow} onValueChange={setPendingShow}>
<SelectTrigger className="w-64">
<SelectValue placeholder={t('admin.collections.addShow')} />
</SelectTrigger>
<SelectContent>
{available.map((show) => (
<SelectItem key={show.id} value={show.id}>
{show.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
disabled={!pendingShow || addMutation.isPending}
onClick={() => addMutation.mutate(pendingShow)}
>
{t('admin.collections.addShow')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.collections.orderHint')}</p>
<div className="crt-panel rounded-md">
{collection.items.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">
{t('admin.collections.empty')}
</p>
) : (
<ul className="divide-y divide-border text-sm">
{collection.items.map((item, index) => (
<li
key={item.showId}
draggable
onDragStart={() => setDragged(item.showId)}
onDragOver={(e) => e.preventDefault()}
onDrop={() => dropOn(item.showId)}
className="flex items-center gap-3 px-4 py-2"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground" />
<span className="w-6 shrink-0 text-muted-foreground">{index + 1}</span>
<Link
to="/admin/shows/$showId"
params={{ showId: item.showId }}
className="min-w-0 flex-1 truncate text-primary hover:underline"
>
{item.showName}
</Link>
{item.year && <span className="text-muted-foreground">{item.year}</span>}
<Badge variant="muted">{t(`admin.shows.kinds.${item.showKind}`)}</Badge>
<span className="text-muted-foreground">
{t('admin.shows.episodes')}: {item.episodeCount}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeMutation.mutate(item.showId)}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUrl } from '@/features/admin/images/api'
import { HttpError } from '@/shared/api/client'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createCollection, deleteCollection, listCollections } from './api'
export function CollectionsPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const { sort, toggle } = useTableSort('name', false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'collections'],
queryFn: listCollections,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'collections'] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({
mutationFn: () => createCollection({ name: name.trim() }),
onSuccess: () => {
setName('')
invalidate()
},
onError,
})
const deleteMutation = useMutation({
mutationFn: deleteCollection,
onSuccess: invalidate,
onError,
})
const rows = sortRows(data ?? [], sort, {
name: (c) => c.name.toLowerCase(),
items: (c) => c.itemCount,
units: (c) => c.unitCount,
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="crt-glow text-xl font-semibold">{t('admin.collections.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.collections.hint')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-xs"
placeholder={t('admin.collections.name')}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button
size="sm"
disabled={!name.trim() || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('common.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<th className="w-16 px-4 py-2 font-medium" />
<SortHeader
label={t('admin.collections.name')}
sortKey="name"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.collections.parts')}
sortKey="items"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.collections.units')}
sortKey="units"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={5}>
{t('common.loading')}
</td>
</tr>
)}
{rows.map((collection) => (
<tr key={collection.id} className="border-b border-border last:border-0">
<td className="px-4 py-2">
<div className="flex h-12 w-8 items-center justify-center overflow-hidden rounded border border-border bg-muted/30">
{collection.posterImageId && (
<img
src={imageUrl(collection.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
)}
</div>
</td>
<td className="px-4 py-2">
<Link
to="/admin/collections/$collectionId"
params={{ collectionId: collection.id }}
className="text-primary hover:underline"
>
{collection.name}
</Link>
</td>
<td className="px-4 py-2 text-muted-foreground">{collection.itemCount}</td>
<td className="px-4 py-2 text-muted-foreground">{collection.unitCount}</td>
<td className="px-4 py-2">
<Button
size="sm"
variant="destructive"
onClick={() => deleteMutation.mutate(collection.id)}
>
{t('common.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
@@ -0,0 +1,46 @@
import { apiRequest } from '@/shared/api/client'
import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types'
export function listCollections() {
return apiRequest<CollectionSummaryDto[]>('/admin/collections')
}
export function getCollection(id: string) {
return apiRequest<CollectionDto>(`/admin/collections/${id}`)
}
export function createCollection(body: { name: string; description?: string }) {
return apiRequest<CreatedIdResponse>('/admin/collections', { method: 'POST', body })
}
export function updateCollection(id: string, body: { name: string; description: string | null }) {
return apiRequest<void>(`/admin/collections/${id}`, { method: 'PUT', body })
}
export function deleteCollection(id: string) {
return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' })
}
export function addCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
}
export function removeCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows/${showId}`, { method: 'DELETE' })
}
/** Порядок частей: не упомянутые остаются после перечисленных. */
export function reorderCollection(id: string, showIdsInOrder: string[]) {
return apiRequest<void>(`/admin/collections/${id}/order`, {
method: 'PUT',
body: { showIdsInOrder },
})
}
/** Привязать/снять постер коллекции (null — отвязать). */
export function setCollectionPoster(id: string, imageId: string | null) {
return apiRequest<void>(`/admin/collections/${id}/poster-image`, {
method: 'PUT',
body: { imageId },
})
}