Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
|
|
import { Progress } from '@/components/heroui/Progress'
|
|
import { addToast } from '@/lib/toast'
|
|
import CloudUploadIcon from '@/components/icons/CloudUploadIcon'
|
|
import TrashIcon from '@/components/icons/TrashIcon'
|
|
import CloseIcon from '@/components/icons/CloseIcon'
|
|
import FileCheckIcon from '@/components/icons/FileCheckIcon'
|
|
import CloudDownloadIcon from '@/components/icons/CloudDownloadIcon'
|
|
import { handleDownload } from '@/helpers'
|
|
import { texts, format } from '@/texts'
|
|
import { truncateValue } from '@/lib/formatters'
|
|
import Button from '@/components/formElements/Button'
|
|
import axiosInstance from '@/config/axios'
|
|
import { prepareImageForUpload } from '@/lib/fileValidation'
|
|
|
|
interface FileItem {
|
|
id: string
|
|
name: string
|
|
size: string
|
|
type?: string
|
|
uploadPercentage: number
|
|
fileId?: string
|
|
}
|
|
|
|
interface UploadFileProps {
|
|
buttonText?: string
|
|
accept?: string[]
|
|
className?: string
|
|
fileUploaded: (fileId: string, mode?: 'save' | 'remove', fileType?: string, fileUrl?: string) => void
|
|
fileId?: string
|
|
noInteraction?: boolean
|
|
inputId?: string
|
|
maxImageFileSize?: number // in MB
|
|
maxVideoFileSize?: number // in MB
|
|
maxAudioFileSize?: number // in MB
|
|
maxTextFileSize?: number // in MB
|
|
}
|
|
|
|
const FileUpload = ({
|
|
buttonText,
|
|
accept = [],
|
|
className = '',
|
|
fileUploaded,
|
|
fileId,
|
|
noInteraction = false,
|
|
inputId,
|
|
maxImageFileSize = 6,
|
|
maxVideoFileSize = 500,
|
|
maxAudioFileSize = 15,
|
|
maxTextFileSize = 10,
|
|
}: UploadFileProps) => {
|
|
const [files, setFiles] = useState<FileItem[]>([])
|
|
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
|
const requestTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
const defaultButtonText = buttonText || texts.common.uploadFile
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
const formatNumber = (number: number) => {
|
|
return parseFloat(number.toFixed(2)).toString()
|
|
}
|
|
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`
|
|
} else if (bytes < 1048576) {
|
|
return `${formatNumber(bytes / 1024)} KB `
|
|
} else if (bytes < 1073741824) {
|
|
return `${formatNumber(bytes / 1048576)} MB `
|
|
} else {
|
|
return `${formatNumber(bytes / 1073741824)} GB `
|
|
}
|
|
}
|
|
|
|
const findFileFormat = (type: string) => {
|
|
if (type.includes('image')) return 'PFT_Image'
|
|
else if (type.includes('video')) return 'PFT_Video'
|
|
else if (type.includes('audio')) return 'PFT_Audio'
|
|
else if (type.includes('pdf')) return 'PFT_Text'
|
|
}
|
|
|
|
const hasType = useCallback((type: string) => accept.some((item) => item?.toLowerCase()?.includes(type)), [accept])
|
|
|
|
const acceptFile = useMemo(() => {
|
|
if (!accept.length) {
|
|
return '*'
|
|
} else {
|
|
const acceptableFormat = []
|
|
|
|
if (hasType('image')) acceptableFormat.push('image/*') // Accept all image types
|
|
if (hasType('audio')) acceptableFormat.push('audio/*') // Accept all audio types
|
|
if (hasType('video')) acceptableFormat.push('video/*') // Accept all video types
|
|
if (hasType('text'))
|
|
acceptableFormat.push(
|
|
'.pdf',
|
|
'.txt',
|
|
'.doc',
|
|
'.pptx',
|
|
'.xlsx',
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'text/plain', // Plain text files
|
|
'text/csv', // CSV files
|
|
'text/html', // HTML files
|
|
'text/markdown', // Markdown files
|
|
'application/msword', // Legacy Word files
|
|
'application/rtf', // RTF files
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
|
)
|
|
|
|
return acceptableFormat.join(',')
|
|
}
|
|
}, [accept, hasType])
|
|
|
|
const hintText = useMemo(() => {
|
|
let text = ''
|
|
|
|
if (hasType('image'))
|
|
text = `JPEG(${maxImageFileSize} MB), PNG(${maxImageFileSize} MB), WebP(${maxImageFileSize} MB), HEIC(${maxImageFileSize} MB)`
|
|
if (hasType('audio'))
|
|
text += text.length
|
|
? `, MP3(${maxAudioFileSize} MB), WAV(${maxAudioFileSize} MB)`
|
|
: `MP3(${maxAudioFileSize} MB), WAV(${maxAudioFileSize} MB)`
|
|
if (hasType('video')) text += text.length ? `, MP4(${maxVideoFileSize} MB)` : `MP4(${maxVideoFileSize} MB)`
|
|
if (hasType('text'))
|
|
text += text.length
|
|
? `, PDF(${maxTextFileSize} MB), TXT(${maxTextFileSize} MB), DOCX(${maxTextFileSize} MB), PPTX(${maxTextFileSize} MB), XLSX(${maxTextFileSize} MB)`
|
|
: `PDF(${maxTextFileSize} MB), TXT(${maxTextFileSize} MB), DOCX(${maxTextFileSize} MB), PPTX(${maxTextFileSize} MB), XLSX(${maxTextFileSize} MB)`
|
|
|
|
return text
|
|
}, [hasType, maxAudioFileSize, maxImageFileSize, maxTextFileSize, maxVideoFileSize])
|
|
|
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = event.target.files?.[0]
|
|
|
|
event.target.value = ''
|
|
|
|
if (file) {
|
|
const textMimeTypes = [
|
|
'text/plain', // Plain text
|
|
'text/csv', // CSV
|
|
'text/html', // HTML
|
|
'text/markdown', // Markdown
|
|
'application/pdf', // PDF
|
|
'application/rtf', // RTF
|
|
'application/msword', // Legacy Word
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word (docx)
|
|
'application/vnd.ms-excel',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/vnd.ms-powerpoint',
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
]
|
|
|
|
if (textMimeTypes.includes(file.type)) {
|
|
// Check if acceptFile includes any text MIME type
|
|
if (!textMimeTypes.some((type) => acceptFile.includes(type))) {
|
|
addToast({
|
|
title: texts.common.fileMustBeAccepted,
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
} else {
|
|
// For other file types, check main MIME type match
|
|
const fileType = file.type.split('/')[0]
|
|
// const acceptedType = acceptFile.split('/')[0]
|
|
|
|
if (!acceptFile.includes(fileType)) {
|
|
addToast({
|
|
title: texts.common.fileMustBeAccepted,
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
const MAX_IMAGE_SIZE_IN_BYTES = maxImageFileSize * 1024 * 1024
|
|
|
|
if (file.type.includes('image') && file.size > MAX_IMAGE_SIZE_IN_BYTES) {
|
|
addToast({
|
|
title: format(texts.common.imageFileTooLarge, { maxMb: maxImageFileSize }),
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
const MAX_VIDEO_SIZE_IN_BYTES = maxVideoFileSize * 1024 * 1024
|
|
|
|
if (file.type.includes('video') && file.size > MAX_VIDEO_SIZE_IN_BYTES) {
|
|
addToast({
|
|
title: format(texts.common.videoFileTooLarge, { maxMb: maxVideoFileSize }),
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
const MAX_AUDIO_SIZE_IN_BYTES = maxAudioFileSize * 1024 * 1024
|
|
|
|
if (file.type.includes('audio') && file.size > MAX_AUDIO_SIZE_IN_BYTES) {
|
|
addToast({
|
|
title: format(texts.common.audioFileTooLarge, { maxMb: maxAudioFileSize }),
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
const MAX_TEXT_SIZE_IN_BYTES = maxTextFileSize * 1024 * 1024
|
|
|
|
if (textMimeTypes.includes(file.type) && file.size > MAX_TEXT_SIZE_IN_BYTES) {
|
|
addToast({
|
|
title: format(texts.common.textFileTooLarge, { maxMb: maxTextFileSize }),
|
|
color: 'danger',
|
|
})
|
|
|
|
return
|
|
}
|
|
|
|
const uploadFile = hasType('image') ? await prepareImageForUpload(file, maxImageFileSize) : file
|
|
|
|
if (typeof uploadFile === 'string') {
|
|
addToast({ title: uploadFile, color: 'danger' })
|
|
|
|
return
|
|
}
|
|
|
|
setFiles((prev) => [
|
|
...prev,
|
|
{
|
|
id: Date.now().toString(),
|
|
name: uploadFile.name,
|
|
size: formatFileSize(uploadFile.size),
|
|
type: findFileFormat(uploadFile.type),
|
|
uploadPercentage: 0,
|
|
},
|
|
])
|
|
|
|
if (requestTimeoutRef.current) clearTimeout(requestTimeoutRef.current)
|
|
|
|
requestTimeoutRef.current = setTimeout(() => {
|
|
handleUpload(uploadFile)
|
|
}, 500)
|
|
}
|
|
}
|
|
|
|
const handleUpload = (file: File) => {
|
|
const data = new FormData()
|
|
|
|
data.append('file', file)
|
|
|
|
const controller = new AbortController()
|
|
|
|
setAbortController(controller)
|
|
|
|
axiosInstance
|
|
.post('/uploads', data, {
|
|
signal: controller.signal,
|
|
onUploadProgress: (progressEvent) => {
|
|
if (progressEvent.total) {
|
|
const uploadPercentage = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
|
|
|
setFiles((prev) => prev.map((f) => (f.name === file.name ? { ...f, uploadPercentage } : f)))
|
|
}
|
|
},
|
|
})
|
|
.then((result) => {
|
|
const payload = result.data as { data?: { id?: unknown; url?: unknown } } | undefined
|
|
const uploaded = payload?.data
|
|
const uploadedId = typeof uploaded?.id === 'string' ? uploaded.id : null
|
|
const uploadedUrl = typeof uploaded?.url === 'string' ? uploaded.url : null
|
|
|
|
if (uploadedId && uploadedUrl) {
|
|
const newFile = {
|
|
id: uploadedId,
|
|
url: uploadedUrl,
|
|
}
|
|
|
|
setFiles((prev) => prev.map((f) => (f.name === file.name ? { ...f, fileId: newFile.id } : f)))
|
|
fileUploaded(newFile.id, 'save', file?.type?.split('/')[0], newFile.url)
|
|
} else {
|
|
addToast({ title: texts.common.uploadFailed, color: 'danger' })
|
|
}
|
|
})
|
|
.catch((error: unknown) => {
|
|
if (typeof error !== 'object' || error === null || !('code' in error) || error.code !== 'ERR_CANCELED') {
|
|
addToast({ title: texts.common.uploadFailed, color: 'danger' })
|
|
}
|
|
})
|
|
}
|
|
|
|
const cancelUpload = () => {
|
|
if (abortController) {
|
|
abortController.abort()
|
|
setFiles((prev) => prev.slice(0, -1))
|
|
setAbortController(null)
|
|
}
|
|
}
|
|
|
|
const removeFile = (fileId: string) => {
|
|
setFiles((prev) => prev.filter((file) => file.id !== fileId))
|
|
fileUploaded(fileId, 'remove')
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{files?.length === 0 && !fileId && (
|
|
<div className={`flex flex-col items-center relative ${className}`}>
|
|
<input
|
|
accept={acceptFile}
|
|
id={'file-upload' + `-${inputId}`}
|
|
style={{ display: 'none' }}
|
|
type="file"
|
|
onChange={handleFileChange}
|
|
/>
|
|
<label
|
|
className="cursor-pointer py-4 px-6 flex flex-col gap-4 items-center border border-background-primary rounded-2xl w-full bg-background-50/30"
|
|
htmlFor={'file-upload' + `-${inputId}`}
|
|
>
|
|
<div className="mx-auto rounded-full size-[53px] md:size-[100px] flex items-center justify-center bg-background-50">
|
|
<CloudUploadIcon className="size-6 md:size-[38px] text-text-dark" />
|
|
</div>
|
|
<div className="flex flex-col items-center text-center gap-3">
|
|
<p className="text-tertiary font-bold text-sm md:text-medium">{defaultButtonText}</p>
|
|
<p className="text-text-light text-sm md:text-medium">{hintText}</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
)}
|
|
{!!fileId && !files?.length && (
|
|
<div className="p-4 rounded-2xl border border-tertiary flex gap-1 justify-between relative">
|
|
<div
|
|
className="flex items-center gap-4 grow me-6 cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => handleDownload(fileId, texts.common.downloadedFileName)}
|
|
onKeyDown={(e) =>
|
|
(e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), handleDownload(fileId, texts.common.downloadedFileName))
|
|
}
|
|
>
|
|
<div className="rounded-full bg-background-primary size-12 flex items-center justify-center">
|
|
<CloudDownloadIcon className="size-6 text-primary" />
|
|
</div>
|
|
<p>{texts.common.downloadFile}</p>
|
|
</div>
|
|
{!noInteraction && (
|
|
<Button
|
|
iconOnly
|
|
aria-label={texts.common.removeFile}
|
|
className="rounded-full absolute top-2 end-2 z-50"
|
|
color="default"
|
|
variant="light"
|
|
onClick={() => {
|
|
fileUploaded(fileId, 'remove')
|
|
}}
|
|
>
|
|
<TrashIcon className="size-5 text-text" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!!files?.length &&
|
|
files.map((file) => (
|
|
<div
|
|
key={file.id}
|
|
className="p-4 rounded-2xl border border-tertiary flex gap-1 justify-between relative"
|
|
>
|
|
{file.uploadPercentage === 100 ? (
|
|
<>
|
|
<div
|
|
className="flex items-center gap-4 grow me-6 cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => {
|
|
if (!file.fileId) return
|
|
void handleDownload(file.fileId, file.name)
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === 'Enter' || e.key === ' ') && file.fileId) {
|
|
e.preventDefault()
|
|
void handleDownload(file.fileId, file.name)
|
|
}
|
|
}}
|
|
>
|
|
<div className="rounded-full bg-background-primary size-12 flex items-center justify-center">
|
|
<CloudDownloadIcon className="size-6 text-primary" />
|
|
</div>
|
|
<p>{texts.common.downloadFile}</p>
|
|
</div>
|
|
<Button
|
|
iconOnly
|
|
aria-label={format(texts.common.removeFileNamed, { name: file.name })}
|
|
className="rounded-full absolute top-2 end-2 z-50"
|
|
color="default"
|
|
variant="light"
|
|
onClick={() => {
|
|
removeFile(file.id)
|
|
}}
|
|
>
|
|
<TrashIcon className="size-5 text-text" />
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="flex gap-4 grow">
|
|
<div className="size-10 bg-tertiary-5 rounded-full flex items-center justify-center">
|
|
<FileCheckIcon className="size-5 text-tertiary-100" />
|
|
</div>
|
|
<div className="flex flex-col gap-1 grow">
|
|
<div className="flex flex-col">
|
|
<p className="text-text-dark text-sm md:text-medium">{truncateValue(file.name, 30)}</p>
|
|
<p
|
|
className="font-light text-end text-sm md:text-medium"
|
|
dir="ltr"
|
|
>
|
|
{file.size}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<p className="font-medium text-text-dark">{file.uploadPercentage}%</p>
|
|
<Progress
|
|
aria-label="Loading..."
|
|
color="secondary"
|
|
value={file.uploadPercentage}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
iconOnly
|
|
aria-label={texts.common.cancelUpload}
|
|
className="rounded-full"
|
|
color="default"
|
|
variant="light"
|
|
onClick={cancelUpload}
|
|
>
|
|
<CloseIcon className="size-5 text-text" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default FileUpload
|