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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user