Files
TeleWave/frontend/src/features/admin/images/ImageGallery.tsx
T
Leonid Pershin 860cde19a3
ci / build-backend (push) Successful in 2m54s
ci / build-frontend (push) Failing after 24s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Refactor GalleryPanel and GalleryBrowser for improved layout and functionality
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.
2026-07-27 11:48:19 +03:00

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