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,287 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { useTranslation } from 'react-i18next'
import { z } from 'zod'
import { HttpError } from '@/shared/api/client'
import type { GenreDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { SortHeader, sortRows, useTableSort } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store'
import { createGenre, deleteGenre, listGenres, updateGenre } from './api'
const createSchema = z.object({
name: z.string().min(1).max(128),
slug: z
.string()
.min(1)
.max(64)
.regex(/^[a-z0-9-]+$/),
aliases: z.string().max(1024).optional(),
})
const editSchema = z.object({
name: z.string().min(1).max(128),
sortOrder: z.number().int().min(0),
aliases: z.string().max(1024).optional(),
})
type CreateValues = z.infer<typeof createSchema>
type EditValues = z.infer<typeof editSchema>
/** Псевдонимы вводятся одной строкой через запятую — их редко больше десятка. */
function parseAliases(value: string | undefined): string[] {
return (value ?? '')
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0)
}
export function GenresPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: genres, isLoading } = useQuery({
queryKey: ['admin', 'genres'],
queryFn: listGenres,
})
const { sort, toggle } = useTableSort('sortOrder', false)
const sortedGenres = sortRows(genres ?? [], sort, {
sortOrder: (g) => g.sortOrder,
name: (g) => g.name.toLowerCase(),
slug: (g) => g.slug,
showCount: (g) => g.showCount,
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'genres'] })
const reportError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const createMutation = useMutation({ mutationFn: createGenre, onSuccess: invalidate })
const updateMutation = useMutation({
mutationFn: ({
id,
...body
}: {
id: string
name: string
sortOrder: number
aliases: string[]
}) => updateGenre(id, body),
onSuccess: invalidate,
})
const deleteMutation = useMutation({
mutationFn: deleteGenre,
onSuccess: invalidate,
onError: reportError,
})
const [createOpen, setCreateOpen] = useState(false)
const [editing, setEditing] = useState<GenreDto | null>(null)
const createForm = useForm<CreateValues>({ resolver: zodResolver(createSchema) })
const editForm = useForm<EditValues>({ resolver: zodResolver(editSchema) })
const openEdit = (genre: GenreDto) => {
setEditing(genre)
editForm.reset({
name: genre.name,
sortOrder: genre.sortOrder,
aliases: genre.aliases.join(', '),
})
}
const onCreate = async (values: CreateValues) => {
try {
await createMutation.mutateAsync({
name: values.name,
slug: values.slug,
aliases: parseAliases(values.aliases),
})
createForm.reset()
setCreateOpen(false)
} catch (error) {
reportError(error)
}
}
const onEdit = async (values: EditValues) => {
if (!editing) return
try {
await updateMutation.mutateAsync({
id: editing.id,
name: values.name,
sortOrder: values.sortOrder,
aliases: parseAliases(values.aliases),
})
setEditing(null)
} catch (error) {
reportError(error)
}
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-1">
<h2 className="crt-glow text-xl font-semibold">{t('admin.genres.title')}</h2>
<p className="text-sm text-muted-foreground">{t('admin.genres.hint')}</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> {t('admin.genres.create')}
</Button>
</div>
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.genres.order')}
sortKey="sortOrder"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.genres.name')}
sortKey="name"
sort={sort}
onToggle={toggle}
/>
<SortHeader
label={t('admin.genres.slug')}
sortKey="slug"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('admin.genres.aliases')}</th>
<SortHeader
label={t('admin.genres.usage')}
sortKey="showCount"
sort={sort}
onToggle={toggle}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
)}
{sortedGenres.map((genre) => (
<tr key={genre.id} className="border-b border-border last:border-0">
<td className="px-4 py-2 text-muted-foreground">{genre.sortOrder}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{genre.name}
{genre.isSystem && <Badge variant="muted">{t('admin.genres.system')}</Badge>}
</div>
</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">{genre.slug}</td>
<td className="max-w-md px-4 py-2 text-xs text-muted-foreground">
{genre.aliases.join(', ')}
</td>
<td className="px-4 py-2">{genre.showCount}</td>
<td className="px-4 py-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => openEdit(genre)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
disabled={genre.isSystem || genre.showCount > 0}
onClick={() => deleteMutation.mutate(genre.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.genres.create')}</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={createForm.handleSubmit(onCreate)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreName">{t('admin.genres.name')}</Label>
<Input id="genreName" {...createForm.register('name')} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreSlug">{t('admin.genres.slug')}</Label>
<Input id="genreSlug" placeholder="action" {...createForm.register('slug')} />
<p className="text-xs text-muted-foreground">{t('admin.genres.slugHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="genreAliases">{t('admin.genres.aliases')}</Label>
<Input
id="genreAliases"
placeholder="tmdb:28, action"
{...createForm.register('aliases')}
/>
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
</div>
<DialogFooter>
<Button type="submit" disabled={createMutation.isPending}>
{t('common.create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.genres.edit')}</DialogTitle>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={editForm.handleSubmit(onEdit)}>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreName">{t('admin.genres.name')}</Label>
<Input id="editGenreName" {...editForm.register('name')} />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreOrder">{t('admin.genres.order')}</Label>
<Input
id="editGenreOrder"
type="number"
{...editForm.register('sortOrder', { valueAsNumber: true })}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="editGenreAliases">{t('admin.genres.aliases')}</Label>
<Input id="editGenreAliases" {...editForm.register('aliases')} />
<p className="text-xs text-muted-foreground">{t('admin.genres.aliasesHint')}</p>
</div>
<DialogFooter>
<Button type="submit" disabled={updateMutation.isPending}>
{t('common.save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}