Add image management functionality: introduce Image entity and related API endpoints, update database schema to support image storage, and enhance UI with a new gallery feature for image selection and upload. Update translations for gallery-related terms.
This commit is contained in:
@@ -513,18 +513,10 @@ function BumperCard({
|
||||
</div>
|
||||
|
||||
{/* Блоки заставок */}
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addTemplate.isPending}
|
||||
onClick={() => addTemplate.mutate()}
|
||||
>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{templates.map((template) => (
|
||||
<BumperTemplateEditor
|
||||
@@ -536,6 +528,16 @@ function BumperCard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={addTemplate.isPending}
|
||||
onClick={() => addTemplate.mutate()}
|
||||
>
|
||||
{t('admin.channels.bumperAddTemplate')}
|
||||
</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
)
|
||||
}
|
||||
@@ -1106,7 +1108,9 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
|
||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||
{entries.slice(0, 40).map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-3 py-1.5">
|
||||
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
|
||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||
{formatTime(e.startsAtUtc)}
|
||||
</span>
|
||||
{e.kind === 'Ad' ? (
|
||||
<Badge variant="muted">{t('air.ad')}</Badge>
|
||||
) : e.kind === 'Bumper' ? (
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GalleryBrowser } from './ImageGallery'
|
||||
|
||||
/** Отдельная страница «Галерея»: просмотр/загрузка/удаление всех изображений приложения. */
|
||||
export function GalleryPanel() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="crt-glow text-xl font-semibold">{t('admin.gallery.title')}</h2>
|
||||
<div className="crt-panel rounded-md p-4">
|
||||
<GalleryBrowser />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, Upload } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ImageCategory } from '@/shared/api/types'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
||||
|
||||
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
||||
|
||||
export type ImagePick = { id: string; url: string }
|
||||
|
||||
/**
|
||||
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
|
||||
* работает как пикер (клик по картинке или загрузка новой возвращает её и закрывает через onClose).
|
||||
* Новые загрузки идут в активную вкладку (по умолчанию — категорию вызова).
|
||||
*/
|
||||
export function GalleryBrowser({
|
||||
category = 'Library',
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
category?: ImageCategory
|
||||
onSelect?: (image: ImagePick) => void
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [active, setActive] = useState<ImageCategory>(category)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const { data: images, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'images', active],
|
||||
queryFn: () => listImages(active),
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'images', active] })
|
||||
|
||||
const pick = (id: string) => {
|
||||
if (!onSelect) return
|
||||
onSelect({ id, url: imageUrl(id) })
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: (file: File) => uploadImage(active, file),
|
||||
onSuccess: (created) => {
|
||||
invalidate()
|
||||
// В режиме пикера загрузка = «загрузить и выбрать»: возвращаем новую картинку и закрываем.
|
||||
if (onSelect) pick(created.id)
|
||||
},
|
||||
onError,
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => deleteImage(id),
|
||||
onSuccess: invalidate,
|
||||
onError,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setActive(c)}
|
||||
className={`rounded-md px-3 py-1.5 text-sm ${
|
||||
active === c
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.gallery.categories.${c}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{onSelect ? t('admin.gallery.pickHint') : t('admin.gallery.browseHint')}
|
||||
</span>
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) upload.mutate(file)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={upload.isPending}
|
||||
onClick={() => fileInput.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.gallery.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 max-h-[55vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : images && images.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
||||
{images.map((img) => (
|
||||
<div key={img.id} className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pick(img.id)}
|
||||
className={`block aspect-square w-full overflow-hidden rounded-md border border-border bg-muted/30 ${
|
||||
onSelect ? 'cursor-pointer hover:border-primary' : 'cursor-default'
|
||||
}`}
|
||||
title={img.originalFileName ?? ''}
|
||||
>
|
||||
<img
|
||||
src={imageUrl(img.id)}
|
||||
alt={img.originalFileName ?? ''}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(img.id)}
|
||||
aria-label={t('common.delete')}
|
||||
className="absolute right-1 top-1 rounded bg-black/60 p-1 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t('admin.gallery.empty')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Общая галерея изображений в модальном окне (используется как пикер в местах выбора картинки). */
|
||||
export function ImageGallery({
|
||||
open,
|
||||
onOpenChange,
|
||||
category,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
category?: ImageCategory
|
||||
onSelect?: (image: ImagePick) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.gallery.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{open && (
|
||||
<GalleryBrowser
|
||||
category={category}
|
||||
onSelect={onSelect}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type { CreatedIdResponse, ImageCategory, ImageDto } from '@/shared/api/types'
|
||||
|
||||
export function listImages(category: ImageCategory) {
|
||||
const q = new URLSearchParams({ category })
|
||||
return apiRequest<ImageDto[]>(`/admin/images?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function deleteImage(id: string) {
|
||||
return apiRequest<void>(`/admin/images/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Публичный URL файла изображения (для <img>). */
|
||||
export function imageUrl(id: string) {
|
||||
return `/api/images/${id}`
|
||||
}
|
||||
|
||||
/** Загрузка изображения в категорию (сырое тело, имя/категория в query — как uploadMedia). */
|
||||
export function uploadImage(category: ImageCategory, file: File): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const q = new URLSearchParams({ fileName: file.name, category })
|
||||
xhr.open('POST', `/api/admin/images?${q.toString()}`)
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} catch {
|
||||
reject(new HttpError({ title: 'Bad response' }, xhr.status))
|
||||
}
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const p = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = p.detail ?? p.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user