admin/components/events/create/EventMediaGalleryUploader.tsx
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

634 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import Image from 'next/image'
import Cropper from 'react-easy-crop'
import { I18nProvider } from '@react-aria/i18n'
import { isAxiosError } from 'axios'
import { texts } from '@/texts'
import type { StagedMediaItem } from '@/components/events/create/types'
import { Slider } from '@/components/heroui/Slider'
import { Progress } from '@/components/heroui/Progress'
import { Chip } from '@/components/heroui/Chip'
import { addToast } from '@/lib/toast'
import Button from '@/components/formElements/Button'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import ImageUploadIcon from '@/components/icons/ImageUploadIcon'
import TrashIcon from '@/components/icons/TrashIcon'
import axiosInstance from '@/config/axios'
import { getCroppedImg, parseUploadedFile } from '@/helpers'
import { SAFE_IMAGE_ACCEPT, prepareImageForUpload } from '@/lib/fileValidation'
import { reportBrowserEvent } from '@/lib/observability/client'
const PORTRAIT_ASPECT_RATIO = 9 / 16
const SQUARE_ASPECT_RATIO = 1
type CropKind = 'portrait' | 'square'
type UploadStage = 'idle' | 'preparing' | 'cropping' | 'uploading'
interface EventMediaGalleryUploaderProps {
items: StagedMediaItem[]
onChange: (items: StagedMediaItem[]) => void
// required?: boolean
}
interface PendingUpload {
image: string
name: string
uploadPercentage: number
kind: CropKind
continueToSquare: boolean
replaceId: string | null
}
// export default function EventMediaGalleryUploader({ items, onChange, required = false }: EventMediaGalleryUploaderProps) {
export default function EventMediaGalleryUploader({ items, onChange }: EventMediaGalleryUploaderProps) {
const [pending, setPending] = useState<PendingUpload | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)
const [crop, setCrop] = useState({ x: 0, y: 0 })
const [zoom, setZoom] = useState(1)
const [croppedAreaPixels, setCroppedAreaPixels] = useState<{ x: number; y: number; width: number; height: number } | null>(null)
const [loading, setLoading] = useState(false)
const [uploadStage, setUploadStage] = useState<UploadStage>('idle')
const squareSourceInputRef = useRef<HTMLInputElement>(null)
const itemsRef = useRef(items)
const activeOperationRef = useRef(0)
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
itemsRef.current = items
}, [items])
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
const portrait = items.find((item) => item.isPoster) ?? null
const square = items.find((item) => item.isSquarePoster) ?? null
const gallery = items.filter((item) => !item.isPoster && !item.isSquarePoster)
const onCropComplete = useCallback((_: unknown, area: { x: number; y: number; width: number; height: number }) => {
setCroppedAreaPixels(area)
}, [])
const resetCrop = () => {
setCrop({ x: 0, y: 0 })
setZoom(1)
setCroppedAreaPixels(null)
}
const openCropper = (
image: string,
name: string,
kind: CropKind,
options?: { continueToSquare?: boolean; replaceId?: string | null }
) => {
resetCrop()
setPending({
image,
name,
uploadPercentage: 0,
kind,
continueToSquare: options?.continueToSquare ?? false,
replaceId: options?.replaceId ?? null,
})
setIsModalOpen(true)
}
const readPreparedFile = async (file: File) => {
const prepared = await prepareImageForUpload(file)
if (typeof prepared === 'string') {
addToast({ title: prepared, color: 'danger' })
return null
}
const image = await readFileAsDataUrl(prepared)
return { image, name: prepared.name, file: prepared }
}
const handleCropFile = async (
file: File | undefined,
kind: CropKind,
options?: { continueToSquare?: boolean; replaceId?: string | null }
) => {
if (!file || loading) return
const operation = ++activeOperationRef.current
setLoading(true)
setUploadStage('preparing')
try {
const prepared = await readPreparedFile(file)
if (!prepared || operation !== activeOperationRef.current) return
openCropper(prepared.image, prepared.name, kind, options)
} catch (error) {
if (operation === activeOperationRef.current) {
reportUploadFailure('preparing', error)
addToast({ title: resolveUploadErrorMessage(error, 'preparing'), color: 'danger' })
}
} finally {
if (operation === activeOperationRef.current) {
setLoading(false)
setUploadStage('idle')
}
}
}
const uploadBlob = async (blob: Blob, onProgress: (percent: number) => void, signal: AbortSignal) => {
const data = new FormData()
data.append('file', blob)
const result = await axiosInstance.post('/uploads', data, {
signal,
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
onProgress(Math.round((progressEvent.loaded / progressEvent.total) * 100))
}
},
})
const uploaded = parseUploadedFile(result.data)
if (!uploaded) {
throw new Error(texts.events.uploadFailed)
}
return uploaded
}
const commitItem = (item: StagedMediaItem, replaceId: string | null) => {
const current = itemsRef.current
const next = replaceId
? current.map((existing) => (existing.id === replaceId ? { ...item, sortOrder: existing.sortOrder } : existing))
: [...current, { ...item, sortOrder: current.length }]
itemsRef.current = next
onChange(next)
}
const uploadImage = async () => {
if (!pending || !croppedAreaPixels || loading) return
const operation = ++activeOperationRef.current
const controller = new AbortController()
let failureStage: UploadStage = 'cropping'
abortControllerRef.current = controller
setLoading(true)
setUploadStage('cropping')
try {
const croppedBlob = await getCroppedImg(pending.image, croppedAreaPixels)
if (operation !== activeOperationRef.current) return
setUploadStage('uploading')
failureStage = 'uploading'
const uploaded = await uploadBlob(
croppedBlob,
(uploadPercentage) => {
if (operation === activeOperationRef.current) {
setPending((prev) => (prev ? { ...prev, uploadPercentage } : prev))
}
},
controller.signal
)
if (operation !== activeOperationRef.current) return
const newItem: StagedMediaItem = {
id: uploaded.id ?? uploaded.url,
url: uploaded.url,
isPoster: pending.kind === 'portrait',
isSquarePoster: pending.kind === 'square',
sortOrder: itemsRef.current.length,
}
commitItem(newItem, pending.replaceId)
if (pending.kind === 'portrait' && pending.continueToSquare) {
openCropper(pending.image, pending.name, 'square')
return
}
setIsModalOpen(false)
setPending(null)
resetCrop()
} catch (error) {
if (operation === activeOperationRef.current && !isCancellation(error)) {
reportUploadFailure(failureStage, error)
addToast({ title: resolveUploadErrorMessage(error, failureStage), color: 'danger' })
}
} finally {
if (operation === activeOperationRef.current) {
setLoading(false)
setUploadStage('idle')
}
if (abortControllerRef.current === controller) abortControllerRef.current = null
}
}
const handleGalleryFile = async (file: File | undefined) => {
if (!file || loading) return
const operation = ++activeOperationRef.current
const controller = new AbortController()
let failureStage: UploadStage = 'preparing'
abortControllerRef.current = controller
setLoading(true)
setUploadStage('preparing')
try {
const prepared = await prepareImageForUpload(file)
if (typeof prepared === 'string') {
addToast({ title: prepared, color: 'danger' })
return
}
if (operation !== activeOperationRef.current) return
setUploadStage('uploading')
failureStage = 'uploading'
const uploaded = await uploadBlob(prepared, () => undefined, controller.signal)
if (operation !== activeOperationRef.current) return
const newItem: StagedMediaItem = {
id: uploaded.id ?? uploaded.url,
url: uploaded.url,
isPoster: false,
isSquarePoster: false,
sortOrder: itemsRef.current.length,
}
const next = [...itemsRef.current, newItem]
itemsRef.current = next
onChange(next)
} catch (error) {
if (operation === activeOperationRef.current && !isCancellation(error)) {
reportUploadFailure(failureStage, error)
addToast({ title: resolveUploadErrorMessage(error, failureStage), color: 'danger' })
}
} finally {
if (operation === activeOperationRef.current) {
setLoading(false)
setUploadStage('idle')
}
if (abortControllerRef.current === controller) abortControllerRef.current = null
}
}
const cancelActiveOperation = () => {
if (!loading) return
activeOperationRef.current += 1
abortControllerRef.current?.abort()
abortControllerRef.current = null
setLoading(false)
setUploadStage('idle')
setPending((current) => (current ? { ...current, uploadPercentage: 0 } : current))
addToast({ title: texts.common.requestCancelled, color: 'warning' })
}
const removeGalleryItem = (id: string) => {
const next = itemsRef.current.filter((item) => item.id !== id).map((item, index) => ({ ...item, sortOrder: index }))
itemsRef.current = next
onChange(next)
}
const closeModal = () => {
setIsModalOpen(false)
setPending(null)
resetCrop()
}
return (
<div className="flex flex-col gap-5">
<div className="w-full grid grid-cols-2 gap-2 items-end">
<span className="label mb-1 block text-sm font-medium !text-secondary-20 col-span-2">تصویر شاخص(پوستر)</span>
<PosterSlot
accept={SAFE_IMAGE_ACCEPT}
aspectClassName="aspect-[9/16]"
disabled={loading}
emptyLabel={texts.events.portraitPosterEmpty}
hint={texts.events.portraitPosterHint}
item={portrait}
onPick={(file) => handleCropFile(file, 'portrait', { continueToSquare: !square, replaceId: portrait?.id ?? null })}
/>
<PosterSlot
accept={SAFE_IMAGE_ACCEPT}
aspectClassName="aspect-square"
disabled={loading}
emptyLabel={texts.events.squarePosterEmpty}
hint={texts.events.squarePosterHint}
item={square}
onPick={(file) => handleCropFile(file, 'square', { replaceId: square?.id ?? null })}
/>
</div>
<div className="flex flex-col gap-3">
<div>
<p className="text-sm font-bold text-foreground">{texts.events.galleryTitle}</p>
<p className="text-xs text-default-500">{texts.events.galleryHint}</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
{gallery.map((item) => (
<div
key={item.id}
className="relative aspect-square overflow-hidden rounded-2xl border border-tertiary"
>
<Image
unoptimized
alt=""
className="h-full w-full object-cover"
height={800}
src={item.url}
width={800}
/>
<Button
iconOnly
aria-label={texts.events.ariaRemoveGalleryImage}
className="absolute end-2 top-2 min-h-11 min-w-11 rounded-full bg-consumer-surface/90"
color="default"
size="sm"
variant="light"
onClick={() => {
removeGalleryItem(item.id)
}}
>
<TrashIcon className="size-4 text-fourth-900" />
</Button>
</div>
))}
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-default-200 bg-default-50">
<input
accept={SAFE_IMAGE_ACCEPT}
className="hidden"
disabled={loading}
type="file"
onChange={(event) => {
void handleGalleryFile(event.target.files?.[0])
event.target.value = ''
}}
/>
<ImageUploadIcon className="size-8 text-default-500" />
<span className="text-xs text-default-600">{texts.events.addImage}</span>
<span className="text-xs text-default-400">{texts.events.noAspectLimit}</span>
</label>
</div>
{loading && !pending ? (
<UploadStatus
stage={uploadStage}
onCancel={cancelActiveOperation}
/>
) : null}
</div>
{pending && (
<ConsumerModal
acceptBtnText={loading ? texts.events.uploading : texts.events.confirmUpload}
isLoading={loading}
isOpen={isModalOpen}
title={pending.kind === 'portrait' ? texts.events.cropPortraitTitle : texts.events.cropSquareTitle}
onAccept={uploadImage}
onClose={closeModal}
onOpenChange={() => {}}
>
<div className="px-2 py-4 flex flex-col gap-4">
{pending.kind === 'square' ? (
<p className="text-sm text-default-600">{texts.events.cropSquareFromPortraitHint}</p>
) : (
<p className="text-sm text-default-600">{texts.events.cropPortraitHint}</p>
)}
<div className="relative w-full h-[300px] rounded-2xl">
<Cropper
aspect={pending.kind === 'portrait' ? PORTRAIT_ASPECT_RATIO : SQUARE_ASPECT_RATIO}
classes={{ containerClassName: 'rounded-2xl' }}
crop={crop}
image={pending.image}
zoom={zoom}
onCropChange={setCrop}
onCropComplete={onCropComplete}
onZoomChange={setZoom}
/>
</div>
{pending.uploadPercentage > 0 && pending.uploadPercentage < 100 && (
<Progress
aria-label="upload"
color="secondary"
value={pending.uploadPercentage}
/>
)}
{loading ? (
<UploadStatus
stage={uploadStage}
onCancel={cancelActiveOperation}
/>
) : null}
<I18nProvider locale="en-US">
<Slider
aria-label="zoom"
dir="ltr"
maxValue={3}
minValue={0.5}
size="sm"
step={0.1}
value={zoom}
onChange={(val) => {
setZoom(val as number)
}}
/>
</I18nProvider>
{pending.kind === 'square' ? (
<>
<input
ref={squareSourceInputRef}
accept={SAFE_IMAGE_ACCEPT}
className="hidden"
disabled={loading}
type="file"
onChange={(event) => {
void handleCropFile(event.target.files?.[0], 'square', { replaceId: pending.replaceId })
event.target.value = ''
}}
/>
<Button
color="default"
disabled={loading}
variant="flat"
onClick={() => squareSourceInputRef.current?.click()}
>
{texts.events.pickOtherSquarePoster}
</Button>
</>
) : null}
</div>
</ConsumerModal>
)}
</div>
)
}
function PosterSlot({
accept,
aspectClassName,
disabled,
emptyLabel,
hint,
item,
label,
onPick,
}: {
accept: string
aspectClassName: string
disabled: boolean
emptyLabel: string
hint: string
item: StagedMediaItem | null
label?: string
onPick: (file: File | undefined) => void
}) {
return (
<div className="flex flex-col gap-2">
{label ? <span className="text-xs font-bold text-foreground">{label}</span> : null}
{item ? (
<label className={`relative ${aspectClassName} cursor-pointer overflow-hidden rounded-2xl border border-tertiary`}>
<input
accept={accept}
className="hidden"
disabled={disabled}
type="file"
onChange={(event) => {
onPick(event.target.files?.[0])
event.target.value = ''
}}
/>
<Image
unoptimized
alt=""
className="h-full w-full object-cover"
height={1920}
src={item.url}
width={1080}
/>
<Chip
className="absolute top-2 start-2"
color="primary"
size="sm"
variant="flat"
>
{texts.events.change}
</Chip>
</label>
) : (
<label
className={`flex ${aspectClassName} cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary-200 bg-primary-50`}
>
<input
accept={accept}
className="hidden"
disabled={disabled}
type="file"
onChange={(event) => {
onPick(event.target.files?.[0])
event.target.value = ''
}}
/>
<ImageUploadIcon className="size-8 text-primary" />
<span className="text-xs text-primary">{emptyLabel}</span>
<span className="text-xs text-default-500">{hint}</span>
</label>
)}
</div>
)
}
function UploadStatus({ stage, onCancel }: { stage: UploadStage; onCancel: () => void }) {
const label = stage === 'uploading' ? texts.events.uploading : texts.events.preparingImage
return (
<div
aria-live="polite"
className="flex items-center justify-between gap-3 rounded-xl bg-default-100 px-3 py-2 text-sm text-default-700"
role="status"
>
<span>{label}</span>
<Button
color="default"
size="sm"
variant="flat"
onClick={onCancel}
>
{texts.events.cancelCurrentUpload}
</Button>
</div>
)
}
function isCancellation(error: unknown): boolean {
return isAxiosError(error) && error.code === 'ERR_CANCELED'
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
const timeout = window.setTimeout(() => {
reader.abort()
reject(new Error('IMAGE_PROCESSING_TIMEOUT'))
}, 30_000)
reader.onload = () => {
window.clearTimeout(timeout)
resolve(reader.result as string)
}
reader.onerror = () => {
window.clearTimeout(timeout)
reject(new Error(texts.events.imageReadFailed))
}
reader.onabort = () => {
window.clearTimeout(timeout)
}
reader.readAsDataURL(file)
})
}
function resolveUploadErrorMessage(error: unknown, stage: UploadStage): string {
if (error instanceof Error && error.message === 'IMAGE_PROCESSING_TIMEOUT') {
return texts.common.imageProcessingTimedOut
}
if (isAxiosError(error) && (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT')) {
return texts.common.imageUploadTimedOut
}
if (stage === 'preparing' && error instanceof Error && error.message === texts.common.imageProcessingTimedOut) {
return error.message
}
return texts.common.imageUploadFailed
}
function reportUploadFailure(stage: UploadStage, error: unknown): void {
reportBrowserEvent({
name: 'event_media_upload_failed',
level: 'warning',
attributes: {
stage,
code: isAxiosError(error) ? (error.code ?? null) : error instanceof Error ? error.message.slice(0, 80) : null,
},
})
}