Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
'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,
|
||
},
|
||
})
|
||
}
|