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
@@ -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>
)
}