'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(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('idle') const squareSourceInputRef = useRef(null) const itemsRef = useRef(items) const activeOperationRef = useRef(0) const abortControllerRef = useRef(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 (
تصویر شاخص(پوستر) handleCropFile(file, 'portrait', { continueToSquare: !square, replaceId: portrait?.id ?? null })} /> handleCropFile(file, 'square', { replaceId: square?.id ?? null })} />

{texts.events.galleryTitle}

{texts.events.galleryHint}

{gallery.map((item) => (
))}
{loading && !pending ? ( ) : null}
{pending && ( {}} >
{pending.kind === 'square' ? (

{texts.events.cropSquareFromPortraitHint}

) : (

{texts.events.cropPortraitHint}

)}
{pending.uploadPercentage > 0 && pending.uploadPercentage < 100 && ( )} {loading ? ( ) : null} { setZoom(val as number) }} /> {pending.kind === 'square' ? ( <> { void handleCropFile(event.target.files?.[0], 'square', { replaceId: pending.replaceId }) event.target.value = '' }} /> ) : null}
)}
) } 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 (
{label ? {label} : null} {item ? ( ) : ( )}
) } function UploadStatus({ stage, onCancel }: { stage: UploadStage; onCancel: () => void }) { const label = stage === 'uploading' ? texts.events.uploading : texts.events.preparingImage return (
{label}
) } function isCancellation(error: unknown): boolean { return isAxiosError(error) && error.code === 'ERR_CANCELED' } function readFileAsDataUrl(file: File): Promise { 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, }, }) }