Add bulk poster filling endpoint and enhance show deletion options
Introduced a new endpoint for bulk filling collection posters, allowing for automatic assignment of posters to collections without existing images. Updated the DeleteShowCommand to include an option for cutting shows from future airings, enhancing the deletion process. Refactored related components and API calls to support these features, ensuring a seamless user experience. Additionally, updated localization strings to reflect the new functionalities and adjusted tests to verify correct behavior.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Image as ImageIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { imageUrl } from '@/features/admin/images/api'
|
||||
@@ -7,9 +8,10 @@ import { qk } from '@/shared/api/query-keys'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { sortRows, useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { createCollection, deleteCollection, listCollections } from './api'
|
||||
import { createCollection, deleteCollection, fillCollectionPosters, listCollections } from './api'
|
||||
import { CollectionSuggestions } from './CollectionSuggestions'
|
||||
|
||||
export function CollectionsPanel() {
|
||||
@@ -39,6 +41,14 @@ export function CollectionsPanel() {
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
const postersMutation = useMutation({
|
||||
mutationFn: () => fillCollectionPosters(),
|
||||
onSuccess: (filled) => {
|
||||
toast.success(t('admin.collections.postersDone', { count: filled }))
|
||||
invalidate()
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
name: (c) => c.name.toLowerCase(),
|
||||
@@ -67,6 +77,19 @@ export function CollectionsPanel() {
|
||||
>
|
||||
{t('common.create')}
|
||||
</Button>
|
||||
|
||||
{/* Постер коллекции — обложка её первой части: своей картинки у франшизы нет, а без
|
||||
обложки она теряется в списке одинаковых строк. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={postersMutation.isPending}
|
||||
onClick={() => postersMutation.mutate()}
|
||||
title={t('admin.collections.postersHint')}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
{t('admin.collections.posters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CollectionSuggestions />
|
||||
|
||||
@@ -39,6 +39,17 @@ export function addShowsToCollection(body: {
|
||||
}
|
||||
|
||||
/** Какие франшизы имеет смысл собрать из уже загруженных полнометражек. */
|
||||
/**
|
||||
* Проставляет постеры по частям коллекций. Без списка — всем, у кого постера ещё нет: после
|
||||
* массового импорта фильмов франшиз сразу десятки. Возвращает, скольким постер нашёлся.
|
||||
*/
|
||||
export function fillCollectionPosters(collectionIds?: string[]) {
|
||||
return apiRequest<number>('/admin/collections/bulk/posters', {
|
||||
method: 'POST',
|
||||
body: { collectionIds: collectionIds ?? null },
|
||||
})
|
||||
}
|
||||
|
||||
export function suggestCollections() {
|
||||
return apiRequest<CollectionSuggestionDto[]>('/admin/collections/suggestions')
|
||||
}
|
||||
|
||||
@@ -26,11 +26,12 @@ export function DeleteShowDialog({
|
||||
}: Readonly<{
|
||||
show: ShowSummaryDto
|
||||
pending: boolean
|
||||
onConfirm: (withMedia: boolean) => void
|
||||
onConfirm: (options: { withMedia: boolean; withSchedule: boolean }) => void
|
||||
onClose: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [withMedia, setWithMedia] = useState(false)
|
||||
const [withSchedule, setWithSchedule] = useState(false)
|
||||
const hasMedia = show.episodeCount > 0
|
||||
|
||||
return (
|
||||
@@ -41,6 +42,24 @@ export function DeleteShowDialog({
|
||||
<DialogDescription>{t('admin.shows.deleteHint', { name: show.name })}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Занятость эфиром — самая частая причина отказа, и лечится она не всегда пересборкой:
|
||||
идущую запись она не трогает, а выключенный канал вещать и не начнёт. Поэтому здесь
|
||||
не только объяснение, но и способ довести удаление до конца. */}
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={withSchedule}
|
||||
onChange={(e) => setWithSchedule(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
{t('admin.shows.deleteWithSchedule')}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t('admin.shows.deleteWithScheduleHint')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{hasMedia && (
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
@@ -66,7 +85,7 @@ export function DeleteShowDialog({
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={pending}
|
||||
onClick={() => onConfirm(withMedia)}
|
||||
onClick={() => onConfirm({ withMedia, withSchedule })}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
|
||||
@@ -115,8 +115,15 @@ export function ShowsPanel() {
|
||||
// Шоу к удалению: сначала спрашиваем про файлы, и только потом удаляем.
|
||||
const [toDelete, setToDelete] = useState<ShowSummaryDto | null>(null)
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ id, withMedia }: { id: string; withMedia: boolean }) =>
|
||||
deleteShow(id, withMedia),
|
||||
mutationFn: ({
|
||||
id,
|
||||
withMedia,
|
||||
withSchedule,
|
||||
}: {
|
||||
id: string
|
||||
withMedia: boolean
|
||||
withSchedule: boolean
|
||||
}) => deleteShow(id, withMedia, withSchedule),
|
||||
onSuccess: () => {
|
||||
setToDelete(null)
|
||||
invalidate()
|
||||
@@ -337,7 +344,7 @@ export function ShowsPanel() {
|
||||
<DeleteShowDialog
|
||||
show={toDelete}
|
||||
pending={deleteMutation.isPending}
|
||||
onConfirm={(withMedia) => deleteMutation.mutate({ id: toDelete.id, withMedia })}
|
||||
onConfirm={(options) => deleteMutation.mutate({ id: toDelete.id, ...options })}
|
||||
onClose={() => setToDelete(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -79,10 +79,17 @@ export function getShowUsage(id: string) {
|
||||
return apiRequest<ShowUsageDto>(`/admin/shows/${id}/usage`)
|
||||
}
|
||||
|
||||
/** `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими. */
|
||||
export function deleteShow(id: string, withMedia = false) {
|
||||
const query = withMedia ? '?withMedia=true' : ''
|
||||
return apiRequest<void>(`/admin/shows/${id}${query}`, { method: 'DELETE' })
|
||||
/**
|
||||
* `withMedia` — снести заодно файлы шоу: иначе они переживут его осиротевшими.
|
||||
* `withSchedule` — вырезать шоу из будущего эфира вместо отказа.
|
||||
*/
|
||||
export function deleteShow(id: string, withMedia = false, withSchedule = false) {
|
||||
const query = new URLSearchParams()
|
||||
if (withMedia) query.set('withMedia', 'true')
|
||||
if (withSchedule) query.set('withSchedule', 'true')
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : ''
|
||||
|
||||
return apiRequest<void>(`/admin/shows/${id}${suffix}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addEpisode(showId: string, mediaAssetId: string) {
|
||||
|
||||
@@ -159,6 +159,10 @@ export const en = {
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
posters: 'Fill posters',
|
||||
postersHint:
|
||||
'Takes the first part’s cover for collections that have no poster yet. A hand-picked one is left alone.',
|
||||
postersDone: 'Posters filled: {{count}}',
|
||||
title: 'Collections',
|
||||
hint: 'A franchise is an ordered set of films played as a single unit.',
|
||||
name: 'Name',
|
||||
@@ -417,6 +421,9 @@ export const en = {
|
||||
deleteWithMedia: 'Delete its media too: {{count}} file(s)',
|
||||
deleteWithMediaHint:
|
||||
'Otherwise the files stay in the media list with no owner and keep taking space.',
|
||||
deleteWithSchedule: 'Cut it from the upcoming air too',
|
||||
deleteWithScheduleHint:
|
||||
'Without this the deletion is refused while the show sits in the feed. Viewers see the filler where it was until the tail is rebuilt.',
|
||||
otherGenres: 'Other genres',
|
||||
bulk: {
|
||||
selected: 'Selected: {{count}}',
|
||||
|
||||
@@ -159,6 +159,10 @@ export const ru = {
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
posters: 'Проставить постеры',
|
||||
postersHint:
|
||||
'Берёт обложку первой части у коллекций, у которых постера ещё нет. Выбранный руками не трогает.',
|
||||
postersDone: 'Постеры проставлены: {{count}}',
|
||||
title: 'Коллекции',
|
||||
hint: 'Франшиза — упорядоченный набор фильмов, который играется как одно целое.',
|
||||
name: 'Название',
|
||||
@@ -414,6 +418,9 @@ export const ru = {
|
||||
deleteWithMedia: 'Также удалить медиа: файлов {{count}}',
|
||||
deleteWithMediaHint:
|
||||
'Иначе файлы останутся в списке медиа ничьими и продолжат занимать место.',
|
||||
deleteWithSchedule: 'Также вырезать из будущего эфира',
|
||||
deleteWithScheduleHint:
|
||||
'Без этого удаление отклонят, пока шоу стоит в ленте. На месте вырезанного зритель увидит фон, пока хвост не пересоберут.',
|
||||
otherGenres: 'Остальные жанры',
|
||||
bulk: {
|
||||
selected: 'Отмечено: {{count}}',
|
||||
|
||||
Reference in New Issue
Block a user