Refactor bumper template background management: rename background upload endpoint to reflect new functionality, update related command and handler to use image ID instead of extension, and adjust data models to support image references. Remove obsolete background handling code and enhance UI components for improved image selection and display.

This commit is contained in:
Leonid Pershin
2026-07-25 11:56:36 +03:00
parent a970eae7d2
commit 7efd616c29
35 changed files with 1227 additions and 279 deletions
@@ -4,6 +4,8 @@ import Hls from 'hls.js'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import { getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
@@ -41,11 +43,11 @@ import {
removeChannelAd,
removeChannelShow,
renderBumperPreview,
setBumperTemplateBackground,
updateBumperTemplate,
updateChannelSettings,
updateChannelShow,
uploadBumperTemplateAudio,
uploadBumperTemplateBackground,
} from './api'
function formatTime(iso: string) {
@@ -654,17 +656,11 @@ function BumperTemplateEditor({
onSaved={onChanged}
onError={onError}
/>
<BumperFileUpload
<BumperBackgroundField
channelId={channelId}
templateId={template.id}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={template.hasBackground}
accept="image/*"
upload={uploadBumperTemplateBackground}
clear={clearBumperTemplateBackground}
onSaved={onChanged}
backgroundImageId={template.backgroundImageId}
onChanged={onChanged}
onError={onError}
/>
</div>
@@ -758,6 +754,71 @@ function BumperPreviewPlayer({
)
}
function BumperBackgroundField({
channelId,
templateId,
backgroundImageId,
onChanged,
onError,
}: {
channelId: string
templateId: string
backgroundImageId: string | null
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [galleryOpen, setGalleryOpen] = useState(false)
const setBg = useMutation({
mutationFn: (imageId: string) => setBumperTemplateBackground(channelId, templateId, imageId),
onSuccess: onChanged,
onError,
})
const clearBg = useMutation({
mutationFn: () => clearBumperTemplateBackground(channelId, templateId),
onSuccess: onChanged,
onError,
})
return (
<div className="flex flex-col gap-1.5">
<Label>
{t('admin.channels.bumperBackground')}{' '}
<span className={backgroundImageId ? 'text-emerald-500' : 'text-muted-foreground'}>
{backgroundImageId
? `· ${t('admin.channels.bumperFileLoaded')}`
: `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{t('admin.channels.bumperBackgroundHint')}</span>
<div className="flex items-center gap-2">
{backgroundImageId && (
<img
src={imageUrl(backgroundImageId)}
alt=""
className="h-10 w-16 shrink-0 rounded border border-border object-cover"
/>
)}
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.channels.bumperBackgroundPick')}
</Button>
{backgroundImageId && (
<Button size="sm" variant="ghost" disabled={clearBg.isPending} onClick={() => clearBg.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="BumperBackground"
onSelect={(img) => setBg.mutate(img.id)}
/>
</div>
)
}
function BumperFileUpload({
channelId,
templateId,
+6 -2
View File
@@ -137,8 +137,12 @@ export function uploadBumperTemplateAudio(id: string, templateId: string, file:
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export function uploadBumperTemplateBackground(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'background', file)
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
@@ -18,7 +18,8 @@ import {
} from '@/features/admin/media/episode-parse'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { ShowMetadataCard } from './ShowMetadataCard'
import { addEpisode, episodeStillUrl, getShow, removeEpisode } from './api'
import { imageUrl } from '@/features/admin/images/api'
import { addEpisode, getShow, removeEpisode } from './api'
type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode }
@@ -219,9 +220,9 @@ export function ShowDetail({ showId }: { showId: string }) {
<td className="px-4 py-2 text-muted-foreground">{index + 1}</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{episode.hasStill && (
{episode.stillImageId && (
<img
src={episodeStillUrl(episode.id)}
src={imageUrl(episode.stillImageId)}
alt=""
className="h-9 w-16 shrink-0 rounded object-cover"
/>
-5
View File
@@ -80,11 +80,6 @@ export function setShowPoster(showId: string, imageId: string | null) {
})
}
/** Ссылка на локальный кадр серии. */
export function episodeStillUrl(episodeId: string, bust?: string) {
return `/api/metadata/episodes/${episodeId}/still${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
}
/** Довыгрузить метаданные серий из привязанного источника. Возвращает число обновлённых. */
export function refreshEpisodesMetadata(showId: string) {
return apiRequest<number>(`/admin/metadata/shows/${showId}/refresh-episodes`, { method: 'POST' })
+3 -3
View File
@@ -7,7 +7,7 @@ import { cn } from '@/shared/lib/cn'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { ChannelPlayer } from './ChannelPlayer'
import { episodeStillUrl, getEpg, imageUrl, listChannels, watchChannel } from './api'
import { getEpg, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -138,9 +138,9 @@ export function AirPage() {
<div className="flex flex-col gap-3">
{current && (
<div className="crt-panel flex gap-3 rounded-md p-3">
{currentEntry?.episodeHasStill && currentEntry.episodeId ? (
{currentEntry?.episodeStillImageId ? (
<img
src={episodeStillUrl(currentEntry.episodeId)}
src={imageUrl(currentEntry.episodeStillImageId)}
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
+1 -5
View File
@@ -1,14 +1,10 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылка на изображение общего реестра (постеры) — по id из публичных DTO. */
/** Ссылка на изображение общего реестра (постеры/кадры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
/** Кадр серии (пока по-старому — публичный эндпоинт метаданных). */
export function episodeStillUrl(episodeId: string) {
return `/api/metadata/episodes/${episodeId}/still`
}
export function listChannels() {
return apiRequest<PublicChannelDto[]>('/channels')