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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { apiRequest } from '@/shared/api/client'
|
||||
import type { CreatedIdResponse, GenreDto } from '@/shared/api/types'
|
||||
|
||||
export function listGenres() {
|
||||
return apiRequest<GenreDto[]>('/admin/genres')
|
||||
}
|
||||
|
||||
export function createGenre(body: { name: string; slug: string; aliases?: string[] }) {
|
||||
return apiRequest<CreatedIdResponse>('/admin/genres', { method: 'POST', body })
|
||||
}
|
||||
|
||||
export function updateGenre(
|
||||
id: string,
|
||||
body: { name: string; sortOrder: number; aliases?: string[] },
|
||||
) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'PUT', body })
|
||||
}
|
||||
|
||||
export function deleteGenre(id: string) {
|
||||
return apiRequest<void>(`/admin/genres/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
Reference in New Issue
Block a user