Updated the GalleryPanel component to ensure it occupies the full screen height, enhancing the user experience by preventing dual scrollbars. Modified the GalleryBrowser to support a new 'fill' prop, allowing it to adapt its layout based on the context, and adjusted the internal scrolling behavior accordingly. These changes improve the overall usability and visual consistency of the image gallery interface.
231 lines
8.3 KiB
TypeScript
231 lines
8.3 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { Trash2, Upload } from 'lucide-react'
|
|
import { useMemo, useRef, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { qk } from '@/shared/api/query-keys'
|
|
import type { ImageCategory } from '@/shared/api/types'
|
|
import { useApiError } from '@/shared/lib/use-api-error'
|
|
import { Button } from '@/shared/ui/button'
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
|
import { cn } from '@/shared/lib/cn'
|
|
import { deleteImage, imageUrl, listImages, uploadImage } from './api'
|
|
|
|
const CATEGORIES: ImageCategory[] = ['Library', 'ShowPoster', 'EpisodeStill', 'BumperBackground']
|
|
|
|
type ImageOrder = 'new' | 'old' | 'az' | 'za'
|
|
|
|
type ImagePick = { id: string; url: string }
|
|
|
|
/**
|
|
* Внутренность галереи: вкладки по категориям, загрузка и удаление. Если передан <c>onSelect</c> —
|
|
* работает как пикер (клик по картинке или загрузка новой возвращает её и закрывает через onClose).
|
|
* Новые загрузки идут в активную вкладку (по умолчанию — категорию вызова).
|
|
*/
|
|
export function GalleryBrowser({
|
|
category = 'Library',
|
|
onSelect,
|
|
onClose,
|
|
fill = false,
|
|
}: Readonly<{
|
|
category?: ImageCategory
|
|
onSelect?: (image: ImagePick) => void
|
|
onClose?: () => void
|
|
/** Занять экран целиком: на отдельной странице прокрутка должна быть одна — внутри сетки. */
|
|
fill?: boolean
|
|
}>) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const [active, setActive] = useState<ImageCategory>(category)
|
|
const fileInput = useRef<HTMLInputElement>(null)
|
|
|
|
const onError = useApiError()
|
|
|
|
const [order, setOrder] = useState<ImageOrder>('new')
|
|
|
|
const { data: images, isLoading } = useQuery({
|
|
queryKey: qk.images.byCategory(active),
|
|
queryFn: () => listImages(active),
|
|
})
|
|
|
|
const sorted = useMemo(() => {
|
|
const arr = [...(images ?? [])]
|
|
arr.sort((a, b) => {
|
|
switch (order) {
|
|
case 'old':
|
|
return a.createdAt.localeCompare(b.createdAt)
|
|
case 'az':
|
|
return (a.originalFileName ?? '').localeCompare(b.originalFileName ?? '')
|
|
case 'za':
|
|
return (b.originalFileName ?? '').localeCompare(a.originalFileName ?? '')
|
|
default:
|
|
return b.createdAt.localeCompare(a.createdAt)
|
|
}
|
|
})
|
|
return arr
|
|
}, [images, order])
|
|
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.images.byCategory(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={cn('flex flex-col', fill && 'min-h-0 flex-1')}>
|
|
<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>
|
|
<Select value={order} onValueChange={(v) => setOrder(v as ImageOrder)}>
|
|
<SelectTrigger className="ml-auto h-8 w-48">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="new">{t('admin.gallery.sort.newest')}</SelectItem>
|
|
<SelectItem value="old">{t('admin.gallery.sort.oldest')}</SelectItem>
|
|
<SelectItem value="az">{t('admin.gallery.sort.nameAsc')}</SelectItem>
|
|
<SelectItem value="za">{t('admin.gallery.sort.nameDesc')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<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={cn(
|
|
'mt-3 overflow-y-auto',
|
|
// В диалоге высота ограничена долей экрана, на своей странице — остатком до низа окна:
|
|
// иначе страница прокручивается вместе с сеткой и полос становится две.
|
|
fill ? 'min-h-0 flex-1' : 'max-h-[55vh]',
|
|
)}
|
|
>
|
|
{isLoading && (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
|
)}
|
|
{!isLoading && sorted.length === 0 && (
|
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
{t('admin.gallery.empty')}
|
|
</p>
|
|
)}
|
|
{!isLoading && sorted.length > 0 && (
|
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5">
|
|
{sorted.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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Общая галерея изображений в модальном окне (используется как пикер в местах выбора картинки). */
|
|
export function ImageGallery({
|
|
open,
|
|
onOpenChange,
|
|
category,
|
|
onSelect,
|
|
}: Readonly<{
|
|
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>
|
|
)
|
|
}
|