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,95 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronRight, Plus, Wand2 } from 'lucide-react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { qk } from '@/shared/api/query-keys'
import type { CollectionSuggestionDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { toast } from '@/shared/ui/toast-store'
import { createCollectionFromSuggestion, suggestCollections } from './api'
/**
* Что стоит собрать во франшизу. Состав виден целиком до создания — по названиям иногда
* промахивается, и проверить глазами должно быть проще, чем разбирать потом.
*/
export function CollectionSuggestions() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const onError = useApiError()
const [collapsed, setCollapsed] = useState(false)
const { data, isLoading } = useQuery({
queryKey: qk.collections.suggestions,
queryFn: suggestCollections,
})
const create = useMutation({
mutationFn: (suggestion: CollectionSuggestionDto) =>
createCollectionFromSuggestion(suggestion.key),
onSuccess: () => {
toast.success(t('admin.collections.suggestions.created'))
void queryClient.invalidateQueries({ queryKey: qk.collections.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
},
onError,
})
if (isLoading || !data || data.length === 0) return null
return (
<div className="flex flex-col gap-2">
<button
type="button"
className="flex items-center gap-2 self-start text-sm font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
onClick={() => setCollapsed((v) => !v)}
>
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
<Wand2 className="h-4 w-4" />
{t('admin.collections.suggestions.title')}
<Badge variant="muted">{data.filter((s) => !s.alreadyExists).length}</Badge>
</button>
{!collapsed && (
<>
<p className="text-xs text-muted-foreground">{t('admin.collections.suggestions.hint')}</p>
<ul className="crt-panel divide-y divide-border rounded-md text-sm">
{data.map((suggestion) => (
<li key={suggestion.key} className="flex flex-col gap-1 px-4 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">{suggestion.name}</span>
<Badge variant={suggestion.source === 'Metadata' ? 'default' : 'muted'}>
{t(`admin.collections.suggestions.sources.${suggestion.source}`)}
</Badge>
<span className="text-muted-foreground">
{t('admin.collections.suggestions.parts', { count: suggestion.parts.length })}
</span>
{suggestion.alreadyExists ? (
<Badge variant="muted">{t('admin.collections.suggestions.exists')}</Badge>
) : (
<Button
size="sm"
variant="outline"
disabled={create.isPending}
onClick={() => create.mutate(suggestion)}
>
<Plus className="h-4 w-4" />
{t('common.create')}
</Button>
)}
</div>
{/* Состав в предлагаемом порядке: по нему и видно, промахнулась ли догадка. */}
<p className="text-xs text-muted-foreground">
{suggestion.parts
.map((part) => (part.year ? `${part.name} (${part.year})` : part.name))
.join(' → ')}
</p>
</li>
))}
</ul>
</>
)}
</div>
)
}
@@ -10,6 +10,7 @@ import { Input } from '@/shared/ui/input'
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { createCollection, deleteCollection, listCollections } from './api'
import { CollectionSuggestions } from './CollectionSuggestions'
export function CollectionsPanel() {
const { t } = useTranslation()
@@ -68,6 +69,8 @@ export function CollectionsPanel() {
</Button>
</div>
<CollectionSuggestions />
<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">
+30 -1
View File
@@ -1,5 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { CollectionDto, CollectionSummaryDto, CreatedIdResponse } from '@/shared/api/types'
import type {
CollectionDto,
CollectionSuggestionDto,
CollectionSummaryDto,
CreatedIdResponse,
} from '@/shared/api/types'
export function listCollections() {
return apiRequest<CollectionSummaryDto[]>('/admin/collections')
@@ -21,6 +26,30 @@ export function deleteCollection(id: string) {
return apiRequest<void>(`/admin/collections/${id}`, { method: 'DELETE' })
}
/** Массовое добавление во франшизу: существующая коллекция либо новая по имени. */
export function addShowsToCollection(body: {
showIds: string[]
collectionId?: string
newCollectionName?: string
}) {
return apiRequest<{ collectionId: string; name: string; added: number }>(
'/admin/collections/bulk/shows',
{ method: 'POST', body },
)
}
/** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */
export function suggestCollections() {
return apiRequest<CollectionSuggestionDto[]>('/admin/collections/suggestions')
}
export function createCollectionFromSuggestion(key: string) {
return apiRequest<CreatedIdResponse>('/admin/collections/suggestions', {
method: 'POST',
body: { key },
})
}
export function addCollectionShow(id: string, showId: string) {
return apiRequest<void>(`/admin/collections/${id}/shows`, { method: 'POST', body: { showId } })
}