Implement image management enhancements: add functionality to relocate legacy images during startup, update image handling in show metadata, and refactor related API endpoints to utilize the new image storage system. Adjust database schema to support image references and update UI components for improved image selection and display.

This commit is contained in:
Leonid Pershin
2026-07-25 11:43:17 +03:00
parent 149cd153b9
commit a970eae7d2
30 changed files with 1231 additions and 172 deletions
@@ -9,6 +9,8 @@ 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'
import { imageUrl } from '@/features/admin/images/api'
import { ImageGallery } from '@/features/admin/images/ImageGallery'
import {
applyMetadata,
clearMetadata,
@@ -16,7 +18,7 @@ import {
refreshEpisodesMetadata,
searchMetadata,
setShowOriginalName,
showPosterUrl,
setShowPoster,
updateMetadata,
uploadPoster,
} from './api'
@@ -24,7 +26,7 @@ import {
export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged: () => void }) {
const { t } = useTranslation()
const posterInput = useRef<HTMLInputElement>(null)
const [bust, setBust] = useState(0)
const [galleryOpen, setGalleryOpen] = useState(false)
const [provider, setProvider] = useState('')
const [originalName, setOriginalName] = useState(show.originalName ?? '')
const [query, setQuery] = useState(show.originalName || show.name)
@@ -40,10 +42,16 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const changed = () => {
setBust(Date.now())
onChanged()
}
const changed = () => onChanged()
const setPoster = useMutation({
mutationFn: (imageId: string) => setShowPoster(show.id, imageId),
onSuccess: () => {
toast.success(t('settings.saved'))
changed()
},
onError,
})
const effectiveProvider = provider || providers?.[0] || ''
@@ -121,9 +129,9 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
{/* Постер */}
<div className="flex w-40 shrink-0 flex-col gap-2">
<div className="flex aspect-[2/3] items-center justify-center overflow-hidden rounded-md border border-border bg-muted/30">
{show.hasPoster ? (
{show.posterImageId ? (
<img
src={showPosterUrl(show.id, String(bust))}
src={imageUrl(show.posterImageId)}
alt=""
className="h-full w-full object-cover"
/>
@@ -150,6 +158,15 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
>
{t('admin.metadata.uploadPoster')}
</Button>
<Button size="sm" variant="outline" onClick={() => setGalleryOpen(true)}>
{t('admin.metadata.pickFromGallery')}
</Button>
<ImageGallery
open={galleryOpen}
onOpenChange={setGalleryOpen}
category="ShowPoster"
onSelect={(img) => setPoster.mutate(img.id)}
/>
</div>
{/* Поиск + ручная правка */}
@@ -282,7 +299,7 @@ export function ShowMetadataCard({ show, onChanged }: { show: ShowDto; onChanged
: t('admin.metadata.refreshEpisodes')}
</Button>
)}
{(show.metadataProvider || show.hasPoster) && (
{(show.metadataProvider || show.posterImageId) && (
<Button
size="sm"
variant="ghost"
+6 -3
View File
@@ -72,9 +72,12 @@ export function clearMetadata(showId: string) {
return apiRequest<void>(`/admin/metadata/shows/${showId}`, { method: 'DELETE' })
}
/** Ссылка на локальный постер шоу (публичный эндпоинт; cache-buster — по флагу наличия). */
export function showPosterUrl(showId: string, bust?: string) {
return `/api/metadata/shows/${showId}/poster${bust ? `?v=${encodeURIComponent(bust)}` : ''}`
/** Привязать/снять постер шоу по ссылке на изображение из реестра (null — отвязать). */
export function setShowPoster(showId: string, imageId: string | null) {
return apiRequest<void>(`/admin/metadata/shows/${showId}/poster-image`, {
method: 'PUT',
body: { imageId },
})
}
/** Ссылка на локальный кадр серии. */
+5 -5
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, listChannels, showPosterUrl, watchChannel } from './api'
import { episodeStillUrl, getEpg, imageUrl, listChannels, watchChannel } from './api'
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
@@ -89,9 +89,9 @@ export function AirPage() {
selected === channel.slug && 'border-primary text-primary',
)}
>
{channel.currentShowHasPoster && channel.currentShowId ? (
{channel.currentShowPosterImageId ? (
<img
src={showPosterUrl(channel.currentShowId)}
src={imageUrl(channel.currentShowPosterImageId)}
alt=""
className="h-10 w-7 shrink-0 rounded object-cover"
/>
@@ -144,9 +144,9 @@ export function AirPage() {
alt=""
className="hidden h-24 w-40 shrink-0 rounded object-cover sm:block"
/>
) : currentEntry?.showHasPoster && current.showId ? (
) : currentEntry?.showPosterImageId ? (
<img
src={showPosterUrl(current.showId)}
src={imageUrl(currentEntry.showPosterImageId)}
alt=""
className="hidden h-24 w-16 shrink-0 rounded object-cover sm:block"
/>
+4 -3
View File
@@ -1,10 +1,11 @@
import { apiRequest } from '@/shared/api/client'
import type { PublicChannelDto, PublicEpgEntryDto } from '@/shared/api/types'
/** Ссылки на локальные постер шоу / кадр серии (публичные эндпоинты метаданных). */
export function showPosterUrl(showId: string) {
return `/api/metadata/shows/${showId}/poster`
/** Ссылка на изображение общего реестра (постеры) — по id из публичных DTO. */
export function imageUrl(imageId: string) {
return `/api/images/${imageId}`
}
/** Кадр серии (пока по-старому — публичный эндпоинт метаданных). */
export function episodeStillUrl(episodeId: string) {
return `/api/metadata/episodes/${episodeId}/still`
}
+3 -3
View File
@@ -117,7 +117,7 @@ export type ShowDto = {
metadataProvider: string | null
metadataExternalId: string | null
year: number | null
hasPoster: boolean
posterImageId: string | null
episodes: EpisodeDto[]
}
@@ -223,7 +223,7 @@ export type PublicChannelDto = {
name: string
currentShowId: string | null
currentShowName: string | null
currentShowHasPoster: boolean
currentShowPosterImageId: string | null
}
export type PublicEpgEntryDto = {
@@ -232,7 +232,7 @@ export type PublicEpgEntryDto = {
endsAtUtc: string
showId: string | null
showName: string | null
showHasPoster: boolean
showPosterImageId: string | null
episodeId: string | null
episodeTitle: string | null
episodeOverview: string | null
+2
View File
@@ -282,6 +282,7 @@ const resources = {
},
metadata: {
title: 'Метаданные',
pickFromGallery: 'Выбрать из галереи',
originalName: 'Оригинальное название (eng)',
originalNamePlaceholder: 'Например: Family Guy',
originalNameHint: 'По нему ищутся метаданные; на экранах показывается обычное название.',
@@ -584,6 +585,7 @@ const resources = {
},
metadata: {
title: 'Metadata',
pickFromGallery: 'Pick from gallery',
originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',