Implement collection suggestions and bulk show addition features
ci / build-backend (push) Successful in 2m41s
ci / build-frontend (push) Successful in 44s
ci / tests (push) Successful in 2m33s
ci / sonar (push) Successful in 5m12s

Added new API endpoints for suggesting collections based on existing shows and for bulk adding shows to collections. Enhanced the backend with necessary logic and DTOs to support these features. Updated the frontend to include new components for displaying collection suggestions and managing bulk additions, improving the user experience for collection management. Localization updates were made to support these new features in both English and Russian.
This commit is contained in:
Leonid Pershin
2026-07-27 05:39:23 +03:00
parent 1a7f73a5bd
commit 5ad28b2966
39 changed files with 2710 additions and 10 deletions
@@ -0,0 +1,114 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { addShowsToCollection, listCollections } from '@/features/admin/collections/api'
import { qk } from '@/shared/api/query-keys'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
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'
/** Значение «завести новую» в списке коллекций: пустая строка у Radix зарезервирована под сброс. */
const NEW = '__new__'
/**
* Куда сложить отмеченные шоу: в существующую франшизу или в новую. Порядок частей сервер
* расставляет по году — правится он потом перетаскиванием в карточке коллекции.
*/
export function AddToCollectionDialog({
showIds,
onClose,
onAdded,
}: Readonly<{ showIds: string[]; onClose: () => void; onAdded: () => void }>) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [target, setTarget] = useState(NEW)
const [name, setName] = useState('')
const { data: collections } = useQuery({
queryKey: qk.collections.all,
queryFn: listCollections,
})
const add = useMutation({
mutationFn: () =>
addShowsToCollection({
showIds,
collectionId: target === NEW ? undefined : target,
newCollectionName: target === NEW ? name.trim() : undefined,
}),
onSuccess: (result) => {
toast.success(
t('admin.shows.bulk.addedToCollection', { name: result.name, count: result.added }),
)
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
onAdded()
onClose()
},
onError,
})
const disabled = add.isPending || (target === NEW && !name.trim())
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('admin.shows.bulk.toCollection')}</DialogTitle>
<DialogDescription>{t('admin.shows.bulk.toCollectionHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.title')}</Label>
<Select value={target} onValueChange={setTarget}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NEW}>{t('admin.shows.bulk.newCollection')}</SelectItem>
{(collections ?? []).map((collection) => (
<SelectItem key={collection.id} value={collection.id}>
{collection.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{target === NEW && (
<div className="flex flex-col gap-1.5">
<Label>{t('admin.collections.name')}</Label>
<Input
autoFocus
value={name}
maxLength={256}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={disabled} onClick={() => add.mutate()}>
{t('admin.shows.bulk.addToCollection', { count: showIds.length })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Sparkles, Tags } from 'lucide-react'
import { Layers, Sparkles, Tags } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listGenres } from '@/features/admin/genres/api'
@@ -10,6 +10,7 @@ import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { bulkEnrichShows, bulkTagShows, getMetadataProviders } from './api'
import { AddToCollectionDialog } from './AddToCollectionDialog'
/** Значение «не трогать рейтинг» в селекте — отдельное от «снять рейтинг» (AUDIENCE_UNSET). */
const KEEP = 'keep'
@@ -30,6 +31,7 @@ export function BulkTagBar({
const onError = useApiError()
const [genreId, setGenreId] = useState('')
const [audience, setAudience] = useState<string>(KEEP)
const [collectionOpen, setCollectionOpen] = useState(false)
const { data: genres } = useQuery({ queryKey: qk.genres.all, queryFn: listGenres })
const { data: providers } = useQuery({
@@ -129,9 +131,22 @@ export function BulkTagBar({
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setCollectionOpen(true)}>
<Layers className="h-4 w-4" />
{t('admin.shows.bulk.toCollection')}
</Button>
<Button size="sm" variant="ghost" onClick={onDone}>
{t('admin.shows.bulk.clear')}
</Button>
{collectionOpen && (
<AddToCollectionDialog
showIds={showIds}
onClose={() => setCollectionOpen(false)}
onAdded={invalidate}
/>
)}
</div>
)
}
@@ -16,7 +16,7 @@ import { AddEpisodesDialog } from './AddEpisodesDialog'
import { ShowGenresField } from './ShowGenresField'
import { ShowMetadataCard } from './ShowMetadataCard'
import { imageUrl } from '@/features/admin/images/api'
import { getShow, removeEpisode, setShowAudience } from './api'
import { getShow, getShowUsage, removeEpisode, setShowAudience } from './api'
const PAGE_SIZE = 20
@@ -30,6 +30,13 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
queryKey: qk.shows.detail(showId),
queryFn: () => getShow(showId),
})
// Где шоу задействовано — отдельным запросом: он обходит все группы, и держать его в карточке
// шоу заодно с остальным значило бы платить за него при каждом обновлении состава.
const { data: usage } = useQuery({
queryKey: qk.shows.usage(showId),
queryFn: () => getShowUsage(showId),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.shows.detail(showId) })
const onError = useApiError()
@@ -134,6 +141,30 @@ export function ShowDetail({ showId }: Readonly<{ showId: string }>) {
))}
</p>
)}
{/* Где шоу задействовано: без этого «используется» при удалении не объясняет, кем. */}
{usage && usage.groups.length > 0 && (
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{t('admin.shows.inGroups')}:
{usage.groups.map((group) => (
<Link
key={group.id}
to="/admin/groups/$groupId"
params={{ groupId: group.id }}
className="text-primary hover:underline"
>
{group.name}
{group.viaRule && (
<span className="text-muted-foreground"> ({t('admin.groups.byRule')})</span>
)}
</Link>
))}
</p>
)}
{usage && usage.channels.length > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
{t('admin.shows.inChannels')}: {usage.channels.map((c) => c.name).join(', ')}
</p>
)}
</div>
<ShowMetadataCard show={show} onChanged={invalidate} />
+6
View File
@@ -9,6 +9,7 @@ import type {
ShowDto,
ShowKind,
ShowSummaryDto,
ShowUsageDto,
} from '@/shared/api/types'
export function listShows(genreId?: string) {
@@ -73,6 +74,11 @@ export function bulkEnrichShows(showIds: string[], provider: string) {
})
}
/** Где шоу задействовано: группы, коллекции, каналы. */
export function getShowUsage(id: string) {
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
}
export function deleteShow(id: string) {
return apiRequest<void>(`/admin/shows/${id}`, { method: 'DELETE' })
}