presigned url
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-10 20:08:31 +03:30
parent 7fd8b4c7e5
commit 7271ea8f21
26 changed files with 186 additions and 123 deletions
+18
View File
@@ -0,0 +1,18 @@
import { type FC, type ImgHTMLAttributes } from "react";
import { usePresignedUrl } from "@/pages/uploader/hooks/usePresignedUrl";
type Props = ImgHTMLAttributes<HTMLImageElement> & {
src?: string;
};
const PresignedImage: FC<Props> = ({ src, alt = "", ...props }) => {
const resolvedSrc = usePresignedUrl(src);
if (!resolvedSrc) {
return null;
}
return <img src={resolvedSrc} alt={alt} {...props} />;
};
export default PresignedImage;
+11 -7
View File
@@ -2,6 +2,7 @@ import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
import { type FC, useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone';
import { clx } from '../helpers/utils';
import PresignedImage from './PresignedImage';
type Props = {
label: string;
@@ -100,16 +101,19 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
return (
<div key={index} className='flex items-center gap-2'>
<div key={index} className='flex relative items-center gap-2'>
<img onClick={() => {
if (props.getCover) {
setCover(item)
props.getCover(item)
}
}} src={item}
<PresignedImage
onClick={() => {
if (props.getCover) {
setCover(item)
props.getCover(item)
}
}}
src={item}
className={clx(
'size-10 rounded-full object-cover',
cover === item ? 'border-2 border-red-400' : ''
)} />
)}
/>
<div onClick={() => handleDeletePreview(index)} className='absolute -left-2 -top-2 shadow-md bg-white size-5 rounded-full flex justify-center items-center'>
<CloseCircle className='size-4 ' color='red' />
</div>
+5 -3
View File
@@ -1,18 +1,20 @@
import { Pause, Play } from "iconsax-react"
import { useEffect, useRef, useState, type FC } from "react"
import { usePresignedUrl } from "@/pages/uploader/hooks/usePresignedUrl"
type VoicePlayerProps = {
url: string
}
const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
const resolvedUrl = usePresignedUrl(url)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (!audio || !resolvedUrl) return
const update = () => {
if (!audio.duration) return
@@ -28,7 +30,7 @@ const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
return () => {
audio.removeEventListener('timeupdate', update)
}
}, [])
}, [resolvedUrl])
const toggle = () => {
if (!audioRef.current) return
@@ -66,7 +68,7 @@ const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
})}
</div>
<audio ref={audioRef} src={url} className="hidden" />
<audio ref={audioRef} src={resolvedUrl} className="hidden" />
</div>
)
}
+1 -1
View File
@@ -73,7 +73,7 @@ const CreateInvoice: FC = () => {
uploadResult?.data?.forEach((item) => {
attachments.push({
type: "uploads_attach",
url: item.url,
url: item.key,
});
});
}
+1 -1
View File
@@ -127,7 +127,7 @@ const UpdateInvoice: FC = () => {
uploadResult?.data?.forEach((item) => {
attachments.push({
type: "uploads_attach",
url: item.url,
url: item.key,
});
});
}
+2 -2
View File
@@ -49,10 +49,10 @@ const CreateLearning: FC = () => {
if (videoFile && coverFile) {
const videoResult = await singleUpload.mutateAsync(videoFile)
values.videoUrl = videoResult?.data?.url ?? ''
values.videoUrl = videoResult?.data?.key ?? ''
const coverResult = await singleUpload.mutateAsync(coverFile)
values.coverUrl = coverResult?.data?.url ?? ''
values.coverUrl = coverResult?.data?.key ?? ''
createLearning.mutate(values, {
onSuccess: () => {
+2 -1
View File
@@ -7,6 +7,7 @@ import { Add } from 'iconsax-react'
import { useGetLearning } from './hooks/useLearningData'
import { type LearningItemType } from './hooks/useLearningData'
import Table from '../../components/Table'
import PresignedImage from '@/components/PresignedImage'
import { type ColumnType } from '../../components/types/TableTypes'
const LearningList: FC = () => {
const [page, setPage] = useState<number>(1)
@@ -19,7 +20,7 @@ const LearningList: FC = () => {
key: 'coverUrl',
width: '100px',
render: (item: LearningItemType) => (
<img src={item.coverUrl} className='w-20 h-12 object-cover rounded' alt={item.title} />
<PresignedImage src={item.coverUrl} className='w-20 h-12 object-cover rounded' alt={item.title} />
)
},
{
+1 -1
View File
@@ -75,7 +75,7 @@ const ConvertToOrders: FC = () => {
if (attachmentFiles.length > 0) {
const result = await multiUpload.mutateAsync(attachmentFiles)
result?.data?.forEach((item) => {
attachments.push(item.url)
attachments.push(item.key)
})
}
+1 -1
View File
@@ -70,7 +70,7 @@ const EditOrder: FC = () => {
if (attachmentFiles.length > 0) {
const result = await multiUpload.mutateAsync(attachmentFiles)
result?.data?.forEach((item) => {
attachments.push(item.url)
attachments.push(item.key)
})
}
@@ -2,6 +2,7 @@ import { type FC, type ReactNode } from 'react'
import { Link } from 'react-router-dom'
import moment from 'moment-jalaali'
import { DocumentText } from 'iconsax-react'
import PresignedImage from '@/components/PresignedImage'
import { Paths } from '@/config/Paths'
import { useGetInvoiceItem } from '@/pages/invoice/hooks/useInvoiceData'
import { formatItemDiscountDisplay } from '@/pages/invoice/utils/invoiceItem'
@@ -80,7 +81,7 @@ const InvoiceItemDetailSection: FC<Props> = ({ invoiceItemId }) => {
<div className="flex flex-col gap-5 md:flex-row">
<div className="size-24 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-gray-50">
{imageUrl ? (
<img
<PresignedImage
src={imageUrl}
alt={invoiceItem.product?.title}
className="size-full object-cover"
+10 -16
View File
@@ -51,25 +51,19 @@ const Order: FC<Props> = ({ addNewItem }) => {
onSubmit: async (values) => {
const attachments: AttachmentsType[] = []
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
attachments.push({
type: 'uploads_attach',
url: item.url
})
})
}
const filesResult = await multiUpload.mutateAsync(files)
filesResult.data?.forEach((item) => {
attachments.push({
type: 'uploads_attach',
url: item.key,
})
})
}
if (voiceFile) {
await singleUpload.mutateAsync(voiceFile, {
onSuccess: (data) => {
attachments.push({
type: 'voice',
url: data?.data?.url
})
}
const voiceResult = await singleUpload.mutateAsync(voiceFile)
attachments.push({
type: 'voice',
url: voiceResult.data.key,
})
}
if (indexOrder === -1) {
@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom'
import { Call, Edit2, Location, Paperclip2, Profile2User, Receipt21, User } from 'iconsax-react'
import { clx } from '@/helpers/utils'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import { Paths } from '@/config/Paths'
import {
getAttachmentUrl,
@@ -201,19 +202,21 @@ const OrderDetailSidebar: FC<Props> = ({
<section className="bg-white rounded-3xl p-5 shadow-sm">
<h3 className="text-sm font-medium text-gray-900 mb-4">پیوستها</h3>
<div className="flex flex-col gap-2">
{attachments.map((url, idx) => {
const { fileName } = getFileNameAndExtensionFromUrl(url)
{attachments.map((key, idx) => {
const { fileName } = getFileNameAndExtensionFromUrl(key)
return (
<a
key={url}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-xl border border-gray-100 bg-gray-50/80 px-3 py-2.5 text-sm text-[#0037FF] hover:bg-blue-50 transition-colors"
<button
key={key}
type="button"
onClick={async () => {
const resolvedUrl = await getPresignedUrl(key)
window.open(resolvedUrl, '_blank', 'noopener,noreferrer')
}}
className="flex items-center gap-2 rounded-xl border border-gray-100 bg-gray-50/80 px-3 py-2.5 text-sm text-[#0037FF] hover:bg-blue-50 transition-colors text-right w-full"
>
<Paperclip2 size={18} className="shrink-0" />
<span className="truncate">{fileName || `پیوست ${idx + 1}`}</span>
</a>
</button>
)
})}
</div>
+2 -1
View File
@@ -6,6 +6,7 @@ import { Link, useParams } from 'react-router-dom'
import { Paths } from '@/config/Paths'
import { clx } from '@/helpers/utils'
import { DocumentText, Printer } from 'iconsax-react'
import PresignedImage from '@/components/PresignedImage'
type Props = {
item: OrderItemType
@@ -66,7 +67,7 @@ const OrderItem: FC<Props> = ({ item, estimatedDays, designerName, index }) => {
<div className="flex flex-col gap-5 md:flex-row">
<div className="size-24 shrink-0 overflow-hidden rounded-xl border border-gray-200 bg-white">
{imageUrl ? (
<img
<PresignedImage
src={imageUrl}
alt={item.product?.title}
className="size-full object-cover"
+10 -16
View File
@@ -55,25 +55,19 @@ const EditOrder: FC<Props> = ({ addNewItem, initialValues }) => {
onSubmit: async (values) => {
const attachments: AttachmentsType[] = []
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
attachments.push({
type: 'uploads_attach',
url: item.url
})
})
}
const filesResult = await multiUpload.mutateAsync(files)
filesResult.data?.forEach((item) => {
attachments.push({
type: 'uploads_attach',
url: item.key,
})
})
}
if (voiceFile) {
await singleUpload.mutateAsync(voiceFile, {
onSuccess: (data) => {
attachments.push({
type: 'voice',
url: data?.data?.url
})
}
const voiceResult = await singleUpload.mutateAsync(voiceFile)
attachments.push({
type: 'voice',
url: voiceResult.data.key,
})
}
if (indexOrder === -1) {
+13 -17
View File
@@ -11,6 +11,7 @@ import { useAddTicket, useGetTickets } from '../hooks/useOrderData'
import { useParams } from 'react-router-dom'
import { toast } from '@/shared/toast'
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import VoicePlayer from '@/components/VoicePlayer'
@@ -44,25 +45,19 @@ const TicketSection: FC = () => {
content: message
}
if (audioFile) {
await singleUpload.mutateAsync(audioFile, {
onSuccess: (data) => {
params.attachments.push({
type: 'voice',
url: data?.data?.url
})
}
const voiceResult = await singleUpload.mutateAsync(audioFile)
params.attachments.push({
type: 'voice',
url: voiceResult.data.key,
})
}
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.map((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url
})
})
}
const filesResult = await multiUpload.mutateAsync(files)
filesResult.data?.forEach((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.key,
})
})
}
@@ -80,7 +75,8 @@ const TicketSection: FC = () => {
})
}
const handleOpenLink = (url: string) => {
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(
url,
'_blank'
+1 -1
View File
@@ -56,7 +56,7 @@ const CreateProduct: FC = () => {
onSuccess: (data) => {
const imagesUrl: string[] = []
data.data?.map((item) => {
imagesUrl.push(item.url)
imagesUrl.push(item.key)
})
values.images = imagesUrl
handleSave(values)
+1 -1
View File
@@ -59,7 +59,7 @@ const UpdateProduct: FC = () => {
onSuccess: (data) => {
const imagesUrl: string[] = [...values.images]
data.data?.map((item) => {
imagesUrl.push(item.url)
imagesUrl.push(item.key)
})
values.images = imagesUrl
handleSave(values)
+1 -1
View File
@@ -58,7 +58,7 @@ const ProductCategory: FC = () => {
if (file) {
upload(file, {
onSuccess: (data) => {
values.avatarUrl = data?.data?.url
values.avatarUrl = data?.data?.key
handleSave(values)
}
})
+2 -1
View File
@@ -1,4 +1,5 @@
import { type FC, useMemo } from 'react'
import PresignedImage from '@/components/PresignedImage'
import { useDeleteCategory, useGetCategory } from '../hooks/useProductData'
import Table from '@/components/Table'
import type { ColumnType, RowDataType } from '@/components/types/TableTypes'
@@ -33,7 +34,7 @@ const CategoryList: FC = () => {
{
title: 'تصویر',
key: 'avatarUrl',
render: (item) => item.avatarUrl ? <img src={item.avatarUrl} className='w-14' alt={item.title} /> : '-'
render: (item) => item.avatarUrl ? <PresignedImage src={item.avatarUrl} className='w-14' alt={item.title} /> : '-'
},
{
title: 'عنوان',
+1 -1
View File
@@ -60,7 +60,7 @@ const UpdateCategory: FC = () => {
if (file) {
upload(file, {
onSuccess: (data) => {
values.avatarUrl = data?.data?.url
values.avatarUrl = data?.data?.key
handleSave(values)
}
})
@@ -3,6 +3,8 @@ import { Paperclip2 } from 'iconsax-react'
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
import PresignedImage from '@/components/PresignedImage'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import { useGetEntityField } from '@/pages/formBuilder/hooks/useFormBuilderData'
import type { RequestDetailItemType } from '../types/Types'
@@ -47,7 +49,8 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
const voiceAttachments = item.attachments?.filter((a) => a.type === 'voice') ?? []
const fileAttachments = item.attachments?.filter((a) => a.type !== 'voice') ?? []
const handleOpenLink = (url: string) => {
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -55,14 +58,19 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
<article className={index > 0 ? 'pt-8 mt-8 border-t border-[#EBEDF5]' : ''}>
<div className="flex items-start gap-5">
<div className="size-20 rounded-xl overflow-hidden bg-[#F5F7FC] shrink-0">
<img
src={productImage || placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
onError={(e) => {
e.currentTarget.src = placeholderProductImage
}}
/>
{productImage ? (
<PresignedImage
src={productImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
/>
) : (
<img
src={placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
@@ -115,19 +123,18 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
<div className="flex flex-wrap gap-3">
{fileAttachments.map((att) =>
isImageUrl(att.url) ? (
<a
<button
key={att.url}
href={att.url}
target="_blank"
rel="noopener noreferrer"
type="button"
onClick={() => handleOpenLink(att.url)}
className="block size-20 rounded-xl overflow-hidden border border-[#EBEDF5] hover:opacity-90 transition-opacity"
>
<img
<PresignedImage
src={att.url}
alt="پیوست"
className="size-full object-cover"
/>
</a>
</button>
) : (
<button
key={att.url}
@@ -2,6 +2,7 @@ import { type FC } from 'react'
import moment from 'moment-jalaali'
import { Paperclip2 } from 'iconsax-react'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import VoicePlayer from '@/components/VoicePlayer'
import type { AttachmentsType } from '@/pages/order/types/Types'
@@ -25,7 +26,8 @@ const TicketMessage: FC<Props> = ({
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
const handleOpenLink = (url: string) => {
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
+10 -16
View File
@@ -54,26 +54,20 @@ const TicketSection: FC<Props> = ({ customerName }) => {
}
if (audioFile) {
await singleUpload.mutateAsync(audioFile, {
onSuccess: (data) => {
params.attachments.push({
type: 'voice',
url: data?.data?.url,
})
},
const voiceResult = await singleUpload.mutateAsync(audioFile)
params.attachments.push({
type: 'voice',
url: voiceResult.data.key,
})
}
if (files.length) {
await multiUpload.mutateAsync(files, {
onSuccess: (data) => {
data?.data?.forEach((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url,
})
})
},
const filesResult = await multiUpload.mutateAsync(files)
filesResult.data?.forEach((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.key,
})
})
}
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { getPresignedUrl } from "../service/UploaderService";
export const usePresignedUrl = (key?: string) => {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!key) {
setUrl(undefined);
return;
}
let cancelled = false;
getPresignedUrl(key)
.then((resolvedUrl) => {
if (!cancelled) {
setUrl(resolvedUrl);
}
})
.catch(() => {
if (!cancelled) {
setUrl(undefined);
}
});
return () => {
cancelled = true;
};
}, [key]);
return url;
};
@@ -1,6 +1,7 @@
import axios from "@/config/axios";
import type {
MultiUploadResponseType,
PresignedUrlResponseType,
SingleUploadResponseType,
} from "../types/Types";
@@ -25,3 +26,11 @@ export const multiUpload = async (files: File[]) => {
);
return data;
};
export const getPresignedUrl = async (key: string) => {
const { data } = await axios.get<PresignedUrlResponseType>(
"/admin/presigned-url",
{ params: { key } },
);
return data.data.url;
};
+12 -9
View File
@@ -1,13 +1,16 @@
import type { BaseResponse } from "@/shared/types/Types";
export type SingleUploadType = {
url: string;
file: {
url: string;
key: string;
bucket: string;
};
export type UploadResultType = {
key: string;
bucket: string;
};
export type SingleUploadResponseType = BaseResponse<SingleUploadType>;
export type MultiUploadResponseType = BaseResponse<SingleUploadType[]>;
export type PresignedUrlResultType = {
key: string;
url: string;
expiresIn: number;
};
export type SingleUploadResponseType = BaseResponse<UploadResultType>;
export type MultiUploadResponseType = BaseResponse<UploadResultType[]>;
export type PresignedUrlResponseType = BaseResponse<PresignedUrlResultType>;