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,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 },
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user