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:
Leonid Pershin
2026-07-25 11:27:34 +03:00
parent 72451f89a8
commit 149cd153b9
34 changed files with 1732 additions and 11 deletions
@@ -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>
)
}
+47
View File
@@ -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)
})
}
+21
View File
@@ -17,6 +17,7 @@ import { Route as RegisterRouteImport } from './routes/register'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as AdminIndexRouteImport } from './routes/admin/index'
import { Route as AdminChannelsRouteImport } from './routes/admin/channels'
import { Route as AdminGalleryRouteImport } from './routes/admin/gallery'
import { Route as AdminMaintenanceRouteImport } from './routes/admin/maintenance'
import { Route as AdminMediaRouteImport } from './routes/admin/media'
import { Route as AdminRolesRouteImport } from './routes/admin/roles'
@@ -68,6 +69,11 @@ const AdminChannelsRoute = AdminChannelsRouteImport.update({
path: '/channels',
getParentRoute: () => AdminRoute,
} as any)
const AdminGalleryRoute = AdminGalleryRouteImport.update({
id: '/gallery',
path: '/gallery',
getParentRoute: () => AdminRoute,
} as any)
const AdminMaintenanceRoute = AdminMaintenanceRouteImport.update({
id: '/maintenance',
path: '/maintenance',
@@ -127,6 +133,7 @@ export interface FileRoutesByFullPath {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -145,6 +152,7 @@ export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -165,6 +173,7 @@ export interface FileRoutesById {
'/register': typeof RegisterRoute
'/settings': typeof SettingsRoute
'/admin/channels': typeof AdminChannelsRouteWithChildren
'/admin/gallery': typeof AdminGalleryRoute
'/admin/maintenance': typeof AdminMaintenanceRoute
'/admin/media': typeof AdminMediaRoute
'/admin/roles': typeof AdminRolesRoute
@@ -187,6 +196,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -205,6 +215,7 @@ export interface FileRouteTypes {
| '/login'
| '/register'
| '/settings'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -224,6 +235,7 @@ export interface FileRouteTypes {
| '/register'
| '/settings'
| '/admin/channels'
| '/admin/gallery'
| '/admin/maintenance'
| '/admin/media'
| '/admin/roles'
@@ -304,6 +316,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AdminChannelsRouteImport
parentRoute: typeof AdminRoute
}
'/admin/gallery': {
id: '/admin/gallery'
path: '/gallery'
fullPath: '/admin/gallery'
preLoaderRoute: typeof AdminGalleryRouteImport
parentRoute: typeof AdminRoute
}
'/admin/maintenance': {
id: '/admin/maintenance'
path: '/maintenance'
@@ -407,6 +426,7 @@ const AdminShowsRouteWithChildren = AdminShowsRoute._addFileChildren(
interface AdminRouteChildren {
AdminChannelsRoute: typeof AdminChannelsRouteWithChildren
AdminGalleryRoute: typeof AdminGalleryRoute
AdminMaintenanceRoute: typeof AdminMaintenanceRoute
AdminMediaRoute: typeof AdminMediaRoute
AdminRolesRoute: typeof AdminRolesRoute
@@ -418,6 +438,7 @@ interface AdminRouteChildren {
const AdminRouteChildren: AdminRouteChildren = {
AdminChannelsRoute: AdminChannelsRouteWithChildren,
AdminGalleryRoute: AdminGalleryRoute,
AdminMaintenanceRoute: AdminMaintenanceRoute,
AdminMediaRoute: AdminMediaRoute,
AdminRolesRoute: AdminRolesRoute,
+7
View File
@@ -36,6 +36,13 @@ function AdminLayout() {
>
{t('admin.channels.title')}
</Link>
<Link
to="/admin/gallery"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
activeProps={{ className: 'border-b-2 border-primary text-primary' }}
>
{t('admin.gallery.title')}
</Link>
<Link
to="/admin/roles"
className={cn('pb-2 uppercase tracking-wide text-muted-foreground hover:text-foreground')}
+4
View File
@@ -0,0 +1,4 @@
import { createFileRoute } from '@tanstack/react-router'
import { GalleryPanel } from '@/features/admin/images/GalleryPanel'
export const Route = createFileRoute('/admin/gallery')({ component: GalleryPanel })
+9
View File
@@ -99,6 +99,15 @@ export type EpisodeDto = {
airDate: string | null
}
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
export type ImageDto = {
id: string
category: ImageCategory
originalFileName: string | null
createdAt: string
}
export type ShowDto = {
id: string
name: string
+26
View File
@@ -149,6 +149,19 @@ const resources = {
Failed: 'Ошибка',
},
},
gallery: {
title: 'Галерея',
upload: 'Загрузить',
empty: 'В этой категории пока нет изображений',
pickHint: 'Выберите изображение или загрузите новое',
browseHint: 'Все изображения приложения по категориям',
categories: {
Library: 'Библиотека',
ShowPoster: 'Постеры шоу',
EpisodeStill: 'Кадры серий',
BumperBackground: 'Фоны заставок',
},
},
shows: {
title: 'Шоу',
name: 'Название',
@@ -438,6 +451,19 @@ const resources = {
Failed: 'Failed',
},
},
gallery: {
title: 'Gallery',
upload: 'Upload',
empty: 'No images in this category yet',
pickHint: 'Pick an image or upload a new one',
browseHint: 'All app images by category',
categories: {
Library: 'Library',
ShowPoster: 'Show posters',
EpisodeStill: 'Episode stills',
BumperBackground: 'Bumper backgrounds',
},
},
shows: {
title: 'Shows',
name: 'Name',