Implement collection suggestions and bulk show addition features
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:
@@ -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">
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -149,6 +149,10 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
|
||||
|
||||
const fresh = (candidates ?? []).filter((c) => !c.alreadyInGroup)
|
||||
const isDynamic = group.mode === 'Dynamic'
|
||||
// Закреплённое, которого черновик правила не находит, в составе всё равно останется.
|
||||
const pinnedNotMatched = group.items.filter(
|
||||
(i) => i.pinned && !(candidates ?? []).some((c) => c.elementId === i.elementId),
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -264,7 +268,16 @@ export function GroupDetail({ groupId }: Readonly<{ groupId: string }>) {
|
||||
>
|
||||
<Search className="h-4 w-4" /> {t('admin.groups.find')}
|
||||
</Button>
|
||||
{candidates !== null && (
|
||||
{candidates !== null && isDynamic && (
|
||||
// У группы по правилу «добавить найденное» не имеет смысла — правило и есть состав.
|
||||
// Показываем, каким станет состав, если сохранить черновик правила.
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('admin.groups.previewComposition', {
|
||||
count: candidates.length + pinnedNotMatched,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{candidates !== null && !isDynamic && (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('admin.groups.found', { total: candidates.length, fresh: fresh.length })}
|
||||
|
||||
@@ -17,6 +17,7 @@ export function GroupsPanel() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const { sort, toggle } = useTableSort('name', false)
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: qk.groups.all, queryFn: listGroups })
|
||||
@@ -34,7 +35,11 @@ export function GroupsPanel() {
|
||||
})
|
||||
const deleteMutation = useMutation({ mutationFn: deleteGroup, onSuccess: invalidate, onError })
|
||||
|
||||
const rows = sortRows(data ?? [], sort, {
|
||||
const term = query.trim().toLowerCase()
|
||||
const matched = term
|
||||
? (data ?? []).filter((g) => g.name.toLowerCase().includes(term))
|
||||
: (data ?? [])
|
||||
const rows = sortRows(matched, sort, {
|
||||
name: (g) => g.name.toLowerCase(),
|
||||
items: (g) => g.itemCount,
|
||||
units: (g) => g.unitCount,
|
||||
@@ -64,6 +69,13 @@ export function GroupsPanel() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
|
||||
<GroupSuggestions />
|
||||
|
||||
<div className="crt-panel overflow-x-auto rounded-md">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
@@ -89,9 +90,14 @@ export function MediaPanel() {
|
||||
const lastActivity = useRef(activity)
|
||||
useEffect(() => {
|
||||
if (lastActivity.current === activity) return
|
||||
// Очередь опустела — говорим об этом вслух: обработка идёт минутами, и следить за чипами,
|
||||
// ожидая нуля, никто не должен. Сравниваем с прошлым состоянием, чтобы не поздравлять
|
||||
// с пустой очередью при каждом заходе на экран.
|
||||
const wasBusy = lastActivity.current !== '0:0'
|
||||
lastActivity.current = activity
|
||||
if (wasBusy && activity === '0:0') toast.success(t('admin.media.queueDrained'))
|
||||
void refetch()
|
||||
}, [activity, refetch])
|
||||
}, [activity, refetch, t])
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
const onError = useApiError()
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export const qk = {
|
||||
all: ['admin', 'shows'] as const,
|
||||
byGenre: (genreId: string | null) => ['admin', 'shows', { genreId }] as const,
|
||||
detail: (id: string) => ['admin', 'shows', id] as const,
|
||||
/** Где шоу задействовано: группы, коллекции, каналы. */
|
||||
usage: (id: string) => ['admin', 'shows', id, 'usage'] as const,
|
||||
},
|
||||
|
||||
groups: {
|
||||
@@ -47,6 +49,7 @@ export const qk = {
|
||||
collections: {
|
||||
all: ['admin', 'collections'] as const,
|
||||
detail: (id: string) => ['admin', 'collections', id] as const,
|
||||
suggestions: ['admin', 'collections', 'suggestions'] as const,
|
||||
},
|
||||
|
||||
genres: {
|
||||
|
||||
@@ -160,6 +160,20 @@ export type ShowSummaryDto = {
|
||||
otherGenres: string[]
|
||||
}
|
||||
|
||||
/** Где шоу задействовано — показывается на его карточке. */
|
||||
export type ShowUsageDto = {
|
||||
groups: ShowUsageRefDto[]
|
||||
collections: ShowUsageRefDto[]
|
||||
channels: ShowUsageRefDto[]
|
||||
}
|
||||
|
||||
type ShowUsageRefDto = {
|
||||
id: string
|
||||
name: string
|
||||
/** Шоу попало в группу правилом, а не явной позицией. */
|
||||
viaRule: boolean
|
||||
}
|
||||
|
||||
type ShowGenreDto = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -218,6 +232,18 @@ export type CollectionDto = {
|
||||
items: CollectionItemDto[]
|
||||
}
|
||||
|
||||
/** Откуда взялось предложение франшизы: данные источника либо догадка по названиям. */
|
||||
export type CollectionSuggestionSource = 'Metadata' | 'NameGuess'
|
||||
|
||||
/** Предложение собрать франшизу: имя и части в предлагаемом порядке. */
|
||||
export type CollectionSuggestionDto = {
|
||||
key: string
|
||||
source: CollectionSuggestionSource
|
||||
name: string
|
||||
parts: { showId: string; name: string; year: number | null }[]
|
||||
alreadyExists: boolean
|
||||
}
|
||||
|
||||
/** Коллекция, в которую входит шоу — для блока на экране шоу. */
|
||||
type ShowCollectionRefDto = {
|
||||
id: string
|
||||
|
||||
@@ -116,6 +116,7 @@ export const en = {
|
||||
restore: 'Restore',
|
||||
dynamicOrderHint:
|
||||
'Order: pinned items first (drag to arrange), then rule matches alphabetically.',
|
||||
previewComposition: 'With this rule the composition would be: {{count}}',
|
||||
pendingFound: 'New matches for this rule: {{count}} —',
|
||||
pendingAdd: 'Add all ({{count}})',
|
||||
elementKinds: { Show: 'Show', Collection: 'Collection' },
|
||||
@@ -165,6 +166,14 @@ export const en = {
|
||||
pickShow: 'Pick a show',
|
||||
empty: 'Collection is empty',
|
||||
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
|
||||
suggestions: {
|
||||
title: 'Franchise suggestions',
|
||||
hint: 'Built from movies not in any collection yet. Check the parts — the name-based guess sometimes misses.',
|
||||
parts: '{{count}} parts',
|
||||
exists: 'already exists',
|
||||
created: 'Collection created',
|
||||
sources: { Metadata: 'from metadata', NameGuess: 'by names' },
|
||||
},
|
||||
},
|
||||
interstitials: {
|
||||
title: 'Clips',
|
||||
@@ -281,6 +290,7 @@ export const en = {
|
||||
uploadingCount: 'Uploading {{done}}/{{total}}',
|
||||
cancelAll: 'Cancel all uploads',
|
||||
skippedDuplicates: 'Skipped duplicates: {{count}}',
|
||||
queueDrained: 'Processing finished — the queue is empty',
|
||||
filterActive: 'Active',
|
||||
filterAll: 'All',
|
||||
name: 'File',
|
||||
@@ -363,12 +373,20 @@ export const en = {
|
||||
enriching: 'Searching…',
|
||||
enriched: 'Metadata applied: {{count}}',
|
||||
moreSkipped: 'Skipped as well: {{count}}',
|
||||
toCollection: 'To collection…',
|
||||
toCollectionHint:
|
||||
'The selected shows go into a franchise. Parts are ordered by release year; drag to rearrange them in the collection card.',
|
||||
newCollection: 'New collection',
|
||||
addToCollection: 'Add ({{count}})',
|
||||
addedToCollection: '“{{name}}” updated: {{count}}',
|
||||
},
|
||||
genresEmpty: 'No genres set',
|
||||
genresEdit: 'Genres',
|
||||
genresHint:
|
||||
'Pick the genres of the show. The primary one is listed; all of them are used for selection.',
|
||||
genrePrimary: 'primary',
|
||||
inGroups: 'In groups',
|
||||
inChannels: 'Scheduled on',
|
||||
inCollections: 'Part of collections',
|
||||
episodes: 'Episodes',
|
||||
episode: 'Episode',
|
||||
|
||||
@@ -116,6 +116,7 @@ export const ru = {
|
||||
restore: 'Вернуть',
|
||||
dynamicOrderHint:
|
||||
'Порядок: сначала закреплённое (перетаскиванием), затем найденное правилом по алфавиту.',
|
||||
previewComposition: 'При этом правиле в составе будет: {{count}}',
|
||||
pendingFound: 'Под правило группы подходит нового: {{count}} —',
|
||||
pendingAdd: 'Добавить все ({{count}})',
|
||||
elementKinds: { Show: 'Шоу', Collection: 'Коллекция' },
|
||||
@@ -165,6 +166,14 @@ export const ru = {
|
||||
pickShow: 'Выберите шоу',
|
||||
empty: 'Коллекция пуста',
|
||||
orderHint: 'Порядок частей задаётся перетаскиванием — в нём они и пойдут в эфир.',
|
||||
suggestions: {
|
||||
title: 'Предложения франшиз',
|
||||
hint: 'Собрано из полнометражек, не входящих ни в одну коллекцию. Проверьте состав — по названиям догадка иногда промахивается.',
|
||||
parts: '{{count}} частей',
|
||||
exists: 'уже есть',
|
||||
created: 'Коллекция создана',
|
||||
sources: { Metadata: 'из метаданных', NameGuess: 'по названиям' },
|
||||
},
|
||||
},
|
||||
interstitials: {
|
||||
title: 'Ролики',
|
||||
@@ -282,6 +291,7 @@ export const ru = {
|
||||
uploadingCount: 'Загрузка {{done}}/{{total}}',
|
||||
cancelAll: 'Отменить все загрузки',
|
||||
skippedDuplicates: 'Пропущено дубликатов: {{count}}',
|
||||
queueDrained: 'Обработка завершена — очередь пуста',
|
||||
filterActive: 'Активные',
|
||||
filterAll: 'Все',
|
||||
name: 'Файл',
|
||||
@@ -364,12 +374,20 @@ export const ru = {
|
||||
enriching: 'Ищем…',
|
||||
enriched: 'Метаданные применены: {{count}}',
|
||||
moreSkipped: 'Ещё пропущено: {{count}}',
|
||||
toCollection: 'В коллекцию…',
|
||||
toCollectionHint:
|
||||
'Отмеченные шоу уйдут во франшизу. Порядок частей — по году выпуска; поправить его можно перетаскиванием в карточке коллекции.',
|
||||
newCollection: 'Новая коллекция',
|
||||
addToCollection: 'Добавить ({{count}})',
|
||||
addedToCollection: '«{{name}}» пополнена: {{count}}',
|
||||
},
|
||||
genresEmpty: 'Жанры не проставлены',
|
||||
genresEdit: 'Жанры',
|
||||
genresHint:
|
||||
'Отметьте жанры шоу. Основной показывается в списке; в отборе контента участвуют все.',
|
||||
genrePrimary: 'основной',
|
||||
inGroups: 'В группах',
|
||||
inChannels: 'В эфире каналов',
|
||||
inCollections: 'Входит в коллекции',
|
||||
episodes: 'Серии',
|
||||
episode: 'Серия',
|
||||
|
||||
Reference in New Issue
Block a user