diff --git a/src/components/core/form/fields/SolidMediaMultipleField.tsx b/src/components/core/form/fields/SolidMediaMultipleField.tsx index f69fc8f6..f51964dc 100644 --- a/src/components/core/form/fields/SolidMediaMultipleField.tsx +++ b/src/components/core/form/fields/SolidMediaMultipleField.tsx @@ -22,12 +22,15 @@ import { FileReaderExt } from "../../../../components/common/FileReaderExt"; import getAcceptedFileTypes from "../../../../helpers/getAcceptedFileTypes"; import { downloadMediaFile } from "../../../../helpers/downloadMediaFile"; import { getExtensionComponent } from "../../../../helpers/registry"; +import { getAbsoluteMediaUrl, openMediaInNewTab, resolveMediaPreviewUrl } from "../../../../helpers/mediaUrl"; +import { getMediaPreviewKind, isLightboxMediaKind } from "../../../../helpers/mediaType"; import { SolidFormFieldWidgetProps, SolidMediaFormFieldWidgetProps } from "../../../../types/solid-core"; import { SolidFieldTooltip } from "../../../../components/common/SolidFieldTooltip"; import { ERROR_MESSAGES } from "../../../../constants/error-messages"; import styles from "./solidFields.module.css"; import { SolidIcon } from "../../../shad-cn-ui"; import { buildMediaFieldKey, getPersistedMediaId } from "./mediaFieldUtils"; + export class SolidMediaMultipleField implements ISolidField { private fieldContext: SolidFieldProps; @@ -159,7 +162,7 @@ export class SolidMediaMultipleField implements ISolidField { export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLightboxUrls, setOpenLightbox }: SolidMediaFormFieldWidgetProps) => { - type MediaFileDetail = { name: string; type: string; size: number; mediaId: number | string | null; fileKey: string; fileUrl: string }; + type MediaFileDetail = { name: string; type: string; size: number; mediaId: number | string | null; fileKey: string; fileUrl: string; originalUrl?: string }; const fieldMetadata = fieldContext.fieldMetadata; const fieldLayoutInfo = fieldContext.field; @@ -190,41 +193,87 @@ export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLi const [deleteMedia] = useDeleteMediaMutation(); useEffect(() => { const fieldValue = formik?.values[fieldLayoutInfo.attrs.name]; - const objectUrls: string[] = []; - - if (Array.isArray(fieldValue) && fieldValue.length > 0) { - const details: MediaFileDetail[] = []; - fieldValue.forEach((file: File | any) => { - if (file instanceof File) { - const fileUrl = URL.createObjectURL(file); - objectUrls.push(fileUrl); - - details.push({ - name: file.name, - type: file.type, - size: file.size, - mediaId: null, - fileKey: buildMediaFieldKey(file), - fileUrl: fileUrl - }); - } else if (typeof file === "object" && file._full_url) { - details.push({ - name: file.originalFileName, - type: file.mimeType, - size: file.fileSize, - mediaId: getPersistedMediaId(file), - fileKey: buildMediaFieldKey(file), - fileUrl: file._full_url - }); + let active = true; + const revokeFns: Array<() => void> = []; + + const resolveFileDetails = async () => { + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + const details = await Promise.all( + fieldValue.map(async (file: File | any): Promise => { + if (file instanceof File) { + const fileUrl = URL.createObjectURL(file); + revokeFns.push(() => URL.revokeObjectURL(fileUrl)); + return { + name: file.name, + type: file.type, + size: file.size, + mediaId: null, + fileKey: buildMediaFieldKey(file), + fileUrl, + originalUrl: fileUrl, + }; + } + + if (typeof file === "object" && file._full_url) { + const resolved = await resolveMediaPreviewUrl(file._full_url); + if (resolved.revoke) { + revokeFns.push(resolved.revoke); + } + return { + name: file.originalFileName, + type: file.mimeType, + size: file.fileSize, + mediaId: getPersistedMediaId(file), + fileKey: buildMediaFieldKey(file), + fileUrl: resolved.url, + originalUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + }; + } + + return null; + }) + ); + + if (!active) { + revokeFns.forEach((fn) => fn()); + return; } - }); - setFileDetails(details); - } else { + + setFileDetails(details.filter((detail): detail is MediaFileDetail => detail !== null)); + return; + } + setFileDetails([]); - } + }; + + resolveFileDetails().catch(() => { + if (!active) return; + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + const details = fieldValue + .map((file: File | any): MediaFileDetail | null => { + if (typeof file === "object" && file._full_url) { + return { + name: file.originalFileName, + type: file.mimeType, + size: file.fileSize, + mediaId: getPersistedMediaId(file), + fileKey: buildMediaFieldKey(file), + fileUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + originalUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + }; + } + return null; + }) + .filter((detail): detail is MediaFileDetail => detail !== null); + setFileDetails(details); + return; + } + setFileDetails([]); + }); return () => { - objectUrls.forEach((url) => URL.revokeObjectURL(url)); + active = false; + revokeFns.forEach((fn) => fn()); }; }, [formik.values, fieldLayoutInfo.attrs.name]); @@ -332,32 +381,25 @@ export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLi } const handleFileView = (url: any) => { - const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx" - ]; - - const fileUrl = url?.fileUrl || ""; - const cleanUrl = fileUrl.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (ext && downloadOnlyExt.includes(ext)) { - const link = document.createElement('a'); - link.href = url.fileUrl; - link.download = ''; // or specify a file name like 'file.pdf' - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const previewKind = getMediaPreviewKind({ + url: url?.originalUrl || url?.fileUrl, + fileName: url?.name, + mimeType: url?.type, + }); - } else { + if (isLightboxMediaKind(previewKind)) { setLightboxUrls?.([ - { src: url.fileUrl, downloadUrl: url.fileUrl }, + { + src: url.fileUrl, + downloadUrl: url.fileUrl, + type: previewKind === "video" ? "video" : undefined + }, ]); setOpenLightbox?.(true); + return; } + + void openMediaInNewTab(url?.originalUrl || url?.fileUrl); } @@ -394,34 +436,41 @@ export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLi } {fileDetails.length > 0 && -
-
+
+
-
-
-

handleFileView(fileDetails[0])}>{fileDetails[0].name}

-
- - -
+
+
+ +
+ +
-
+
+
{formatFileSize(fileDetails[0].size)}
@@ -456,35 +505,44 @@ export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLi fileDetails.map((file, index) => { const fileId = file.fileKey; return ( -
-
- -
-
-

handleFileView(file)}>{file.name}

-
- +
+
+
+ +
+
+
+ + +
+
+
+ {formatFileSize(file.size)}
-
-
- {formatFileSize(file.size)}
@@ -521,7 +579,7 @@ export const DefaultMediaMultipleFormEditWidget = ({ formik, fieldContext, setLi } export const DefaultMediaMultipleFormViewWidget = ({ formik, fieldContext, setLightboxUrls, setOpenLightbox }: SolidMediaFormFieldWidgetProps) => { - const [fileDetails, setFileDetails] = useState<{ name: string; type: string; size: number, id: number, fileUrl: string }[]>([]); + const [fileDetails, setFileDetails] = useState<{ name: string; type: string; size: number; id: number | string; fileUrl: string; originalUrl?: string }[]>([]); const [isShowAllFiles, setShowAllFiles] = useState(false); const fieldMetadata = fieldContext.fieldMetadata; const fieldLayoutInfo = fieldContext.field; @@ -539,70 +597,104 @@ export const DefaultMediaMultipleFormViewWidget = ({ formik, fieldContext, setLi useEffect(() => { const fieldValue = formik?.values[fieldLayoutInfo.attrs.name]; + let active = true; + const revokeFns: Array<() => void> = []; + + const resolveFileDetails = async () => { + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + const details = await Promise.all( + fieldValue.map(async (file: File | any) => { + if (file instanceof File) { + const fileUrl = URL.createObjectURL(file); + revokeFns.push(() => URL.revokeObjectURL(fileUrl)); + return { + name: file.name, + type: file.type, + size: file.size, + id: `${file.name}-${file.size}`, + fileUrl, + originalUrl: fileUrl, + }; + } + + if (typeof file === "object" && file._full_url) { + const resolved = await resolveMediaPreviewUrl(file._full_url); + if (resolved.revoke) { + revokeFns.push(resolved.revoke); + } + return { + name: file.originalFileName, + type: file.mimeType, + size: file.fileSize, + id: file.id, + fileUrl: resolved.url, + originalUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + }; + } + + return null; + }) + ); + + if (!active) { + revokeFns.forEach((fn) => fn()); + return; + } + + setFileDetails(details.filter(Boolean) as { name: string; type: string; size: number; id: number | string; fileUrl: string; originalUrl?: string }[]); + return; + } + + setFileDetails([]); + }; - if (Array.isArray(fieldValue) && fieldValue.length > 0) { - const urls: string[] = []; - const details: { name: string; type: string; size: number, id: any, fileUrl: string }[] = []; - const objectUrls: string[] = []; - fieldValue.forEach((file: File | any) => { - if (file instanceof File) { - // New file (from local upload) - const fileUrl = URL.createObjectURL(file); - objectUrls.push(fileUrl); // Store URL for cleanup - urls.push(fileUrl); - - details.push({ - name: file.name, - type: file.type, - size: file.size, - id: `${file.name}-${file.size}`, - fileUrl: fileUrl // ✅ Store the generated object URL - }); - } else if (typeof file === "object" && file._full_url) { - urls.push(file._full_url); - details.push({ + resolveFileDetails().catch(() => { + if (!active) return; + if (Array.isArray(fieldValue) && fieldValue.length > 0) { + setFileDetails(fieldValue + .filter((file: File | any) => typeof file === "object" && file._full_url) + .map((file: File | any) => ({ name: file.originalFileName, type: file.mimeType, size: file.fileSize, id: file.id, - fileUrl: file._full_url - }); - } - }); - setFileDetails(details); - } + fileUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + originalUrl: getAbsoluteMediaUrl(file._full_url || file.relativeUri), + }))); + return; + } + setFileDetails([]); + }); + + return () => { + active = false; + revokeFns.forEach((fn) => fn()); + }; }, [formik.values, fieldLayoutInfo.attrs.name]); const handleFileView = (url: any) => { - const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx" - ]; - - const fileUrl = url?.fileUrl || ""; - const cleanUrl = fileUrl.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (ext && downloadOnlyExt.includes(ext)) { - const link = document.createElement('a'); - link.href = url.fileUrl; - link.download = ''; // or specify a file name like 'file.pdf' - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const previewKind = getMediaPreviewKind({ + url: url?.originalUrl || url?.fileUrl, + fileName: url?.name, + mimeType: url?.type, + }); - } else { + if (isLightboxMediaKind(previewKind)) { setLightboxUrls?.([ - { src: url.fileUrl, downloadUrl: url.fileUrl }, + { + src: url.fileUrl, + downloadUrl: url.fileUrl, + type: previewKind === "video" ? "video" : undefined + }, ]); setOpenLightbox?.(true); + return; } + + void openMediaInNewTab(url?.originalUrl || url?.fileUrl); } - const renderMediaFileCard = (file: { name: string; type: string; size: number; id: number; fileUrl: string }, className = "") => ( + const renderMediaFileCard = (file: { name: string; type: string; size: number; id: number | string; fileUrl: string; originalUrl?: string }, className = "") => (
@@ -621,7 +713,7 @@ export const DefaultMediaMultipleFormViewWidget = ({ formik, fieldContext, setLi type="button" className="solid-file-icon-btn" aria-label="Download file" - onClick={() => downloadMediaFile(file?.fileUrl, file?.name)} + onClick={() => downloadMediaFile(file?.originalUrl || file?.fileUrl, file?.name)} > diff --git a/src/components/core/form/fields/SolidMediaSingleField.tsx b/src/components/core/form/fields/SolidMediaSingleField.tsx index 2added98..6fcc2eb6 100644 --- a/src/components/core/form/fields/SolidMediaSingleField.tsx +++ b/src/components/core/form/fields/SolidMediaSingleField.tsx @@ -21,6 +21,8 @@ import { FileReaderExt } from "../../../../components/common/FileReaderExt"; import getAcceptedFileTypes from "../../../../helpers/getAcceptedFileTypes"; import { downloadMediaFile } from "../../../../helpers/downloadMediaFile"; import { getExtensionComponent } from "../../../../helpers/registry"; +import { getAbsoluteMediaUrl, openMediaInNewTab, resolveMediaPreviewUrl } from "../../../../helpers/mediaUrl"; +import { getMediaPreviewKind, isLightboxMediaKind } from "../../../../helpers/mediaType"; import { SolidMediaFormFieldWidgetProps } from "../../../../types/solid-core"; import { SolidFieldTooltip } from "../../../../components/common/SolidFieldTooltip"; import { ERROR_MESSAGES } from "../../../../constants/error-messages"; @@ -28,6 +30,14 @@ import styles from "./solidFields.module.css"; import { SolidIcon } from "../../../shad-cn-ui"; import { getPersistedMediaId } from "./mediaFieldUtils"; +type SingleMediaFileDetail = { + name: string; + type: string; + fileUrl: string; + fileSize: number; + originalUrl?: string; +}; + export class SolidMediaSingleField implements ISolidField { private fieldContext: SolidFieldProps; @@ -170,7 +180,7 @@ export const DefaultMediaSingleFormEditWidget = ({ formik, fieldContext, setLigh const isFieldReadonly = formReadonly || fieldReadonly || readOnlyPermission; const [isDeleteImageDialogVisible, setDeleteImageDialogVisible] = useState(false); - const [fileDetails, setFileDetails] = useState<{ name: string; type: string, fileUrl: string, fileSize: number } | null>(null); + const [fileDetails, setFileDetails] = useState(null); const [isReplaceImageDialogVisible, setReplaceImageDialogVisible] = useState(false); const [newFileToUpload, setNewFileToUpload] = useState(null); const [fileSizeError, setFileSizeError] = useState(null); @@ -272,34 +282,71 @@ export const DefaultMediaSingleFormEditWidget = ({ formik, fieldContext, setLigh useEffect(() => { const fieldValue = formik?.values[fieldLayoutInfo.attrs.name]; + let active = true; + const revokeFns: Array<() => void> = []; + + const resolveFileDetails = async () => { + if (fieldValue && typeof fieldValue === "object") { + let fileUrl = ""; + let fileName = "Unknown File"; + let fileSize = 0; + let originalUrl = ""; + + if (fieldValue instanceof File) { + fileUrl = URL.createObjectURL(fieldValue); + revokeFns.push(() => URL.revokeObjectURL(fileUrl)); + originalUrl = fileUrl; + fileName = fieldValue.name; + fileSize = fieldValue.size; + } else if (fieldValue._full_url) { + const resolved = await resolveMediaPreviewUrl(fieldValue._full_url); + if (resolved.revoke) { + revokeFns.push(resolved.revoke); + } + fileUrl = resolved.url; + originalUrl = getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri); + fileName = fieldValue.originalFileName; + fileSize = fieldValue.fileSize; + } - if (fieldValue && typeof fieldValue === "object") { - let fileUrl = ""; - let fileName = "Unknown File"; - let fileSize = 0; - - if (fieldValue instanceof File) { - fileUrl = URL.createObjectURL(fieldValue); - fileName = fieldValue.name; - fileSize = fieldValue.size; - } else if (fieldValue._full_url) { - fileUrl = fieldValue._full_url; - fileName = fieldValue.originalFileName; - fileSize = fieldValue.fileSize; - } + if (!active) { + revokeFns.forEach((fn) => fn()); + return; + } - setFileDetails({ - name: fileName, - type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, - fileUrl, - fileSize - }); + setFileDetails({ + name: fileName, + type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, + fileUrl, + fileSize, + originalUrl, + }); + + formik.setFieldValue(fieldLayoutInfo.attrs.name, fieldValue); + return; + } - // Ensure formik has the correct value - formik.setFieldValue(fieldLayoutInfo.attrs.name, fieldValue); - } else { setFileDetails(null); - } + }; + + resolveFileDetails().catch(() => { + if (!active) return; + if (fieldValue && typeof fieldValue === "object" && fieldValue._full_url) { + setFileDetails({ + name: fieldValue.originalFileName, + type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, + fileUrl: getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri), + fileSize: fieldValue.fileSize, + originalUrl: getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri), + }); + return; + } + setFileDetails(null); + }); + return () => { + active = false; + revokeFns.forEach((fn) => fn()); + }; }, [formik.values, fieldLayoutInfo.attrs.name]); const { @@ -332,32 +379,25 @@ export const DefaultMediaSingleFormEditWidget = ({ formik, fieldContext, setLigh } const handleFileView = (url: any) => { - const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx" - ]; - - const fileUrl = url?.fileUrl || ""; - const cleanUrl = fileUrl.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (ext && downloadOnlyExt.includes(ext)) { - const link = document.createElement('a'); - link.href = url.fileUrl; - link.download = ''; // or specify a file name like 'file.pdf' - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const previewKind = getMediaPreviewKind({ + url: url?.originalUrl || url?.fileUrl, + fileName: url?.name, + mimeType: url?.type, + }); - } else { + if (isLightboxMediaKind(previewKind)) { setLightboxUrls?.([ - { src: url.fileUrl, downloadUrl: url.fileUrl }, + { + src: url.fileUrl, + downloadUrl: url.fileUrl, + type: previewKind === "video" ? "video" : undefined + }, ]); setOpenLightbox?.(true); + return; } + + void openMediaInNewTab(url?.originalUrl || url?.fileUrl); } @@ -412,7 +452,7 @@ export const DefaultMediaSingleFormEditWidget = ({ formik, fieldContext, setLigh className="solid-file-icon-btn" disabled={isFieldDisabled || isFieldReadonly} aria-label="Download file" - onClick={() => downloadMediaFile(fileDetails?.fileUrl, fileDetails?.name)} + onClick={() => downloadMediaFile(fileDetails?.originalUrl || fileDetails?.fileUrl, fileDetails?.name)} > @@ -484,7 +524,7 @@ export const DefaultMediaSingleFormEditWidget = ({ formik, fieldContext, setLigh } export const DefaultMediaSingleFormViewWidget = ({ formik, fieldContext, setLightboxUrls, setOpenLightbox }: SolidMediaFormFieldWidgetProps) => { - const [fileDetails, setFileDetails] = useState<{ name: string; type: string, fileUrl: string, fileSize: number } | null>(null); + const [fileDetails, setFileDetails] = useState(null); const fieldMetadata = fieldContext.fieldMetadata; const fieldLayoutInfo = fieldContext.field; const className = fieldLayoutInfo.attrs?.className || 'field w-full px-2 pt-2'; @@ -502,60 +542,91 @@ export const DefaultMediaSingleFormViewWidget = ({ formik, fieldContext, setLigh useEffect(() => { const fieldValue = formik?.values[fieldLayoutInfo.attrs.name]; + let active = true; + const revokeFns: Array<() => void> = []; + + const resolveFileDetails = async () => { + if (fieldValue && typeof fieldValue === "object") { + let fileUrl = ""; + let fileName = "Unknown File"; + let fileSize = 0; + let originalUrl = ""; + + if (fieldValue instanceof File) { + fileUrl = URL.createObjectURL(fieldValue); + revokeFns.push(() => URL.revokeObjectURL(fileUrl)); + originalUrl = fileUrl; + fileName = fieldValue.name; + fileSize = fieldValue.size; + } else if (fieldValue._full_url) { + const resolved = await resolveMediaPreviewUrl(fieldValue._full_url); + if (resolved.revoke) { + revokeFns.push(resolved.revoke); + } + fileUrl = resolved.url; + originalUrl = getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri); + fileName = fieldValue.originalFileName; + fileSize = fieldValue.fileSize; + } + + if (!active) { + revokeFns.forEach((fn) => fn()); + return; + } - if (fieldValue && typeof fieldValue === "object") { - let fileUrl = ""; - let fileName = "Unknown File"; - let fileSize = 0; - - if (fieldValue instanceof File) { - fileUrl = URL.createObjectURL(fieldValue); - fileName = fieldValue.name; - fileSize = fieldValue.size; - } else if (fieldValue._full_url) { - fileUrl = fieldValue._full_url; - fileName = fieldValue.originalFileName; - fileSize = fieldValue.fileSize; + setFileDetails({ + name: fileName, + type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, + fileUrl, + fileSize, + originalUrl, + }); + return; } - setFileDetails({ - name: fileName, - type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, - fileUrl, - fileSize - }); - // Ensure formik has the correct value - formik.setFieldValue(fieldLayoutInfo.attrs.name, fieldValue); - } + setFileDetails(null); + }; + + resolveFileDetails().catch(() => { + if (!active) return; + if (fieldValue && typeof fieldValue === "object" && fieldValue._full_url) { + setFileDetails({ + name: fieldValue.originalFileName, + type: fieldValue.mimeType ? fieldValue.mimeType : fieldValue.type, + fileUrl: getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri), + fileSize: fieldValue.fileSize, + originalUrl: getAbsoluteMediaUrl(fieldValue._full_url || fieldValue.relativeUri), + }); + return; + } + setFileDetails(null); + }); + return () => { + active = false; + revokeFns.forEach((fn) => fn()); + }; }, [formik.values, fieldLayoutInfo.attrs.name]); const handleFileView = (url: any) => { - const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx" - ]; - - const fileUrl = url?.fileUrl || ""; - const cleanUrl = fileUrl.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (ext && downloadOnlyExt.includes(ext)) { - const link = document.createElement('a'); - link.href = url.fileUrl; - link.download = ''; // or specify a file name like 'file.pdf' - link.target = '_blank'; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const previewKind = getMediaPreviewKind({ + url: url?.originalUrl || url?.fileUrl, + fileName: url?.name, + mimeType: url?.type, + }); - } else { + if (isLightboxMediaKind(previewKind)) { setLightboxUrls?.([ - { src: url.fileUrl, downloadUrl: url.fileUrl }, + { + src: url.fileUrl, + downloadUrl: url.fileUrl, + type: previewKind === "video" ? "video" : undefined + }, ]); setOpenLightbox?.(true); + return; } + + void openMediaInNewTab(url?.originalUrl || url?.fileUrl); } return ( @@ -585,7 +656,7 @@ export const DefaultMediaSingleFormViewWidget = ({ formik, fieldContext, setLigh type="button" className="solid-file-icon-btn" aria-label="Download file" - onClick={() => downloadMediaFile(fileDetails?.fileUrl, fileDetails?.name)} + onClick={() => downloadMediaFile(fileDetails?.originalUrl || fileDetails?.fileUrl, fileDetails?.name)} > diff --git a/src/components/core/list/columns/SolidMediaMultipleColumn.tsx b/src/components/core/list/columns/SolidMediaMultipleColumn.tsx index 0a58f1d3..bf135c84 100644 --- a/src/components/core/list/columns/SolidMediaMultipleColumn.tsx +++ b/src/components/core/list/columns/SolidMediaMultipleColumn.tsx @@ -1,49 +1,32 @@ import React, { useState } from 'react'; -import { Download, Volume2 } from "lucide-react"; +import { Volume2 } from "lucide-react"; import { Column } from "../SolidDataTable"; import { SolidListViewColumnParams } from '../SolidListViewColumn'; import { SolidMediaListFieldWidgetProps } from '../../../../types/solid-core'; import { getExtensionComponent } from '../../../../helpers/registry'; import { FileReaderExt } from '../../../../components/common/FileReaderExt'; -import { SolidButton, SolidDialog } from "../../../shad-cn-ui"; +import { SolidDialog, SolidDialogBody, SolidDialogClose, SolidDialogHeader, SolidDialogTitle, SolidIcon } from "../../../shad-cn-ui"; import { SolidFileTypeIcon } from '../../../../helpers/fileTypeIcon'; - -const getCleanUrl = (url: string) => url.split("?")[0]; - -const isImageFile = (url: string) => /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(getCleanUrl(url)); - -const isVideoFile = (url: string) => /\.(mp4|webm|ogg)$/i.test(getCleanUrl(url)); - -const isAudioFile = (url: string) => /\.(mp3|wav|ogg)$/i.test(getCleanUrl(url)); - -const isDocumentType = (url: string) => { - const ext = getCleanUrl(url).split(".").pop()?.toLowerCase(); - return ext ? downloadOnlyExt.includes(ext) : false; -}; - -const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx", - "pdf", "csv" -]; - -const downloadFile = (url: string, name: string = "") => { - const link = document.createElement("a"); - link.href = url; - link.download = name; - link.target = "_blank"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); -}; +import { getMediaPreviewKind, isLightboxMediaKind, type MediaPreviewKind } from '../../../../helpers/mediaType'; +import { getAbsoluteMediaUrl, openMediaInNewTab } from '../../../../helpers/mediaUrl'; +import { downloadMediaFile } from '../../../../helpers/downloadMediaFile'; +import styles from "../../form/fields/solidFields.module.css"; // Thumbnail preview component -const MediaPreview = ({ src, onClick }: { src: string; onClick: (event: React.MouseEvent) => void }) => { +const MediaPreview = ({ + src, + fileName, + previewKind, + onClick +}: { + src: string; + fileName?: string; + previewKind: MediaPreviewKind; + onClick: (event: React.MouseEvent) => void +}) => { const [isBroken, setIsBroken] = useState(false); const handleClick = (event: React.MouseEvent) => { @@ -51,7 +34,7 @@ const MediaPreview = ({ src, onClick }: { src: string; onClick: (event: React.Mo }; if (!isBroken) { - if (isImageFile(src)) { + if (previewKind === "image") { return ( - +
); }; @@ -155,7 +138,13 @@ export const DefaultMediaMultipleListWidget = ({ rowData, fieldMetadata, setLigh type: file.mimeType, size: file.fileSize, id: file.id, - fileUrl: file._full_url + fileUrl: getAbsoluteMediaUrl(file?._full_url || file?.relativeUri), + originalUrl: getAbsoluteMediaUrl(file?._full_url || file?.relativeUri), + previewKind: getMediaPreviewKind({ + url: file?._full_url || file?.relativeUri, + fileName: file?.originalFileName, + mimeType: file?.mimeType, + }) })); @@ -164,49 +153,63 @@ export const DefaultMediaMultipleListWidget = ({ rowData, fieldMetadata, setLigh const handleFileView = (file: any) => { - const fileUrl = file?.fileUrl || ""; - // const cleanUrl = fileUrl.split("?")[0]; - // const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (isDocumentType(fileUrl)) { - downloadFile(file?.fileUrl, "") - - } else { - setLightboxUrls?.([{ src: file.fileUrl, downloadUrl: file.fileUrl }]); + if (isLightboxMediaKind(file?.previewKind)) { + setLightboxUrls?.([{ + src: file.fileUrl, + downloadUrl: file.fileUrl, + type: file.previewKind === "video" ? "video" : undefined + }]); setOpenLightbox?.(true); + return; } + + void openMediaInNewTab(file?.originalUrl || file?.fileUrl); }; + const renderMediaFileCard = (file: any, className = "") => ( +
+
+ +
+
+ +
+ +
+
+
+ {formatFileSize(file.size)} +
+
+
+
+ ); return fullrecord.length > 0 ? (
- {/* THUMBNAIL - FIXED BEHAVIOR */} { event.stopPropagation(); - - // const cleanUrl = fullrecord[0]?.fileUrl.split("?")[0]; - // const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (isDocumentType(fullrecord[0]?.fileUrl) && fullrecord?.length > 1) { - setShowAllFiles(true) - return; - } - - else if (isDocumentType(fullrecord[0]?.fileUrl)) { - downloadFile(fullrecord[0]?.fileUrl, "") - return; - } - // FIRST FILE IS MEDIA ⇒ OPEN LIGHTBOX - const urlsWithType = fullrecord.map((file: any) => ({ - src: file.fileUrl, - downloadUrl: file.fileUrl, - })); - setLightboxUrls(urlsWithType); - setOpenLightbox(true); + handleFileView(fullrecord[0]); }} /> @@ -219,17 +222,7 @@ export const DefaultMediaMultipleListWidget = ({ rowData, fieldMetadata, setLigh }} onClick={(event) => { event.stopPropagation(); - - if (isDocumentType(fullrecord[0]?.fileUrl)) { - setShowAllFiles(true); - } else { - const urlsWithType = fullrecord.map((file: any) => ({ - src: file.fileUrl, - downloadUrl: file.fileUrl - })); - setLightboxUrls(urlsWithType); - setOpenLightbox(true); - } + setShowAllFiles(true); }} > +{fullrecord.length - 1} @@ -242,48 +235,22 @@ export const DefaultMediaMultipleListWidget = ({ rowData, fieldMetadata, setLigh - {fullrecord?.map((file: any) => { - const fileId = `${file.name}-${file.size}`; - return ( -
-
- - - -
-
- -

handleFileView(file)} - > - {file?.name} -

- - } - onClick={() => { - downloadFile(file?.fileUrl, "") - }} - style={{ height: 16, width: 16 }} - /> -
- -
{formatFileSize(file.size)}
-
- + + Items Uploaded + + + + {fullrecord?.map((file: any, index: number) => { + const fileId = `${file.name}-${file.size}`; + return ( +
+ {renderMediaFileCard(file)}
-
- ) - } - )} + ) + })} +
) : ( diff --git a/src/components/core/list/columns/SolidMediaSingleColumn.tsx b/src/components/core/list/columns/SolidMediaSingleColumn.tsx index 69e87829..b2b2d676 100644 --- a/src/components/core/list/columns/SolidMediaSingleColumn.tsx +++ b/src/components/core/list/columns/SolidMediaSingleColumn.tsx @@ -6,48 +6,32 @@ import { SolidListViewColumnParams } from '../SolidListViewColumn'; import { SolidMediaListFieldWidgetProps } from '../../../../types/solid-core'; import { getExtensionComponent } from '../../../../helpers/registry'; import { SolidFileTypeIcon } from '../../../../helpers/fileTypeIcon'; - -const getCleanUrl = (url: string) => url.split("?")[0]; - -// Helpers for file type detection -const isImageFile = (url: string) => /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(getCleanUrl(url)); -const isVideoFile = (url: string) => /\.(mp4|webm|ogg)$/i.test(getCleanUrl(url)); -const isAudioFile = (url: string) => /\.(mp3|wav|ogg)$/i.test(getCleanUrl(url)); - -// Extensions that should be downloaded directly -const downloadOnlyExt = [ - "txt", "zip", "rar", - "doc", "docx", - "xls", "xlsx", - "ppt", "pptx", - "pdf" -]; +import { getMediaPreviewKind, isLightboxMediaKind, type MediaPreviewKind } from '../../../../helpers/mediaType'; +import { getAbsoluteMediaUrl, openMediaInNewTab } from '../../../../helpers/mediaUrl'; // Media component with fallback for broken links -const MediaWithFallback = ({ src, alt, onClick }: { src: string; alt: string; onClick: (event: React.MouseEvent) => void }) => { +const MediaWithFallback = ({ + src, + alt, + fileName, + previewKind, + onClick +}: { + src: string; + alt: string; + fileName?: string; + previewKind: MediaPreviewKind; + onClick: (event: React.MouseEvent) => void +}) => { const [isBroken, setIsBroken] = useState(false); const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); - const cleanUrl = src.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); - - if (ext && downloadOnlyExt.includes(ext)) { - // Trigger download for docs/archives - const link = document.createElement("a"); - link.href = src; - link.download = ""; - link.target = "_blank"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } else { - onClick(event); - } + onClick(event); }; if (!isBroken) { - if (isImageFile(src)) { + if (previewKind === "image") { return ( - +
); }; @@ -150,22 +134,43 @@ export const DefaultMediaSingleListWidget = ({ setOpenLightbox }: SolidMediaListFieldWidgetProps) => { if (!rowData?._media?.[fieldMetadata.name]) return null; - const mediaUrls = rowData._media[fieldMetadata.name].map((i: any) => i._full_url); - - const firstUrl = mediaUrls[0]; - if (!firstUrl) return
; - const cleanUrl = getCleanUrl(firstUrl); + const mediaFiles = rowData._media[fieldMetadata.name].map((file: any) => { + const fileUrl = getAbsoluteMediaUrl(file?._full_url || file?.relativeUri); + const previewKind = getMediaPreviewKind({ + url: fileUrl, + fileName: file?.originalFileName, + mimeType: file?.mimeType, + }); + return { + fileUrl, + fileName: file?.originalFileName, + previewKind, + opensInLightbox: isLightboxMediaKind(previewKind), + lightboxType: previewKind === "video" ? "video" : undefined, + }; + }); + + const firstFile = mediaFiles[0]; + if (!firstFile?.fileUrl) return
; return ( { - // Only open lightbox for image, video, or audio - event.stopPropagation() - if (isImageFile(cleanUrl) || isVideoFile(cleanUrl) || isAudioFile(cleanUrl)) { - setLightboxUrls([{ src: firstUrl, downloadUrl: firstUrl }]); + event.stopPropagation(); + if (firstFile.opensInLightbox) { + setLightboxUrls([{ + src: firstFile.fileUrl, + downloadUrl: firstFile.fileUrl, + type: firstFile.lightboxType + }]); setOpenLightbox(true); + return; } + + void openMediaInNewTab(firstFile.fileUrl); }} /> ); diff --git a/src/helpers/downloadMediaFile.tsx b/src/helpers/downloadMediaFile.tsx index f4b9d067..2a555b9e 100644 --- a/src/helpers/downloadMediaFile.tsx +++ b/src/helpers/downloadMediaFile.tsx @@ -1,12 +1,60 @@ -import { ERROR_MESSAGES } from "../constants/error-messages"; -import { env } from "../adapters/env"; +import { getSession } from "../adapters/auth"; +import { getAbsoluteMediaUrl, isProtectedMediaUrl } from "./mediaUrl"; -export const downloadMediaFile = (fileUrl: string, fileName?: string) => { +const triggerBrowserDownload = (url: string, fileName?: string) => { const link = document.createElement("a"); - link.href = fileUrl; + link.href = url; link.download = fileName || ""; link.target = "_blank"; document.body.appendChild(link); link.click(); document.body.removeChild(link); }; + +const getFileNameFromDisposition = (contentDisposition: string | null): string => { + if (!contentDisposition) { + return ""; + } + + const utfNameMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i); + if (utfNameMatch?.[1]) { + return decodeURIComponent(utfNameMatch[1]); + } + + const fileNameMatch = contentDisposition.match(/filename="?([^"]+)"?/i); + return fileNameMatch?.[1] || ""; +}; + +export const downloadMediaFile = async (fileUrl: string, fileName?: string) => { + if (!fileUrl) { + return; + } + + if (fileUrl.startsWith("blob:") || fileUrl.startsWith("data:")) { + triggerBrowserDownload(fileUrl, fileName); + return; + } + + const absoluteUrl = getAbsoluteMediaUrl(fileUrl); + if (!isProtectedMediaUrl(fileUrl)) { + triggerBrowserDownload(absoluteUrl, fileName); + return; + } + + const session = await getSession(); + const headers = new Headers(); + if (session?.user?.accessToken) { + headers.set("Authorization", `Bearer ${session.user.accessToken}`); + } + + const response = await fetch(absoluteUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to download media: ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const resolvedName = fileName || getFileNameFromDisposition(response.headers.get("Content-Disposition")); + triggerBrowserDownload(objectUrl, resolvedName); + setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; diff --git a/src/helpers/fileTypeIcon.tsx b/src/helpers/fileTypeIcon.tsx index 072fd615..fb741c4f 100644 --- a/src/helpers/fileTypeIcon.tsx +++ b/src/helpers/fileTypeIcon.tsx @@ -27,18 +27,26 @@ const FILE_TYPE_ICON_MAP: Record = { wav: { bg: "#DB2777", corner: "#F9A8D4" } }; -const getFileExtension = (fileUrl: string) => { - const cleanUrl = fileUrl.split("?")[0]; - return cleanUrl.split(".").pop()?.toLowerCase() ?? ""; +const getFileExtension = (...values: Array) => { + for (const value of values) { + if (!value) continue; + const cleanValue = value.split("?")[0]; + const extension = cleanValue.split(".").pop()?.toLowerCase(); + if (extension) { + return extension; + } + } + + return ""; }; -export const getFileTypeIconTone = (fileUrl: string) => { - const extension = getFileExtension(fileUrl); +export const getFileTypeIconTone = (fileUrl: string, fileName?: string) => { + const extension = getFileExtension(fileName, fileUrl); return FILE_TYPE_ICON_MAP[extension] ?? DEFAULT_FILE_ICON; }; -export const SolidFileTypeIcon = ({ fileUrl, size = 24, className }: { fileUrl: string; size?: number; className?: string }) => { - const icon = getFileTypeIconTone(fileUrl); +export const SolidFileTypeIcon = ({ fileUrl, fileName, size = 24, className }: { fileUrl: string; fileName?: string; size?: number; className?: string }) => { + const icon = getFileTypeIconTone(fileUrl, fileName); return ( { - if (!url) return "image"; +// Extracts a lowercase file extension from a URL or filename. +export const getMediaExtension = (value?: string): string => { + if (!value) return ""; - const cleanUrl = url.split("?")[0]; - const ext = cleanUrl.split(".").pop()?.toLowerCase(); + const cleanValue = value.split("?")[0]; + return cleanValue.split(".").pop()?.toLowerCase() ?? ""; +}; + +// Maps a mime type to a supported media kind when possible. +const getMediaKindFromMimeType = (mimeType?: string): MediaKind | null => { + const normalizedMimeType = String(mimeType || "").toLowerCase(); + if (normalizedMimeType.startsWith("image/")) { + return "image"; + } + + if (normalizedMimeType.startsWith("video/")) { + return "video"; + } + + if (normalizedMimeType.startsWith("audio/")) { + return "audio"; + } + + return null; +}; + +// Maps a file extension to a supported media kind when possible. +const getMediaKindFromExtension = (extension?: string): MediaKind | null => { + if (!extension) return null; - if (!ext) return "image"; + if (imageExtensions.includes(extension)) { + return "image"; + } - if (videoExtensions.includes(ext)) { + if (videoExtensions.includes(extension)) { return "video"; } - if (audioExtensions.includes(ext)) { + if (audioExtensions.includes(extension)) { return "audio"; } + return null; +}; + +// Decides whether a file should be treated as lightbox media or a generic file. +export const getMediaPreviewKind = ({ url, fileName, mimeType}: { url?: string; fileName?: string; mimeType?: string; }): MediaPreviewKind => { + const mimeKind = getMediaKindFromMimeType(mimeType); + if (mimeKind) { + return mimeKind; + } + + const extensionKind = getMediaKindFromExtension( + getMediaExtension(fileName) || getMediaExtension(url) + ); + + return extensionKind ?? "file"; +}; + +// Restricts lightbox support to image and video media kinds. +export const isLightboxMediaKind = ( + mediaKind: MediaPreviewKind +): mediaKind is "image" | "video" => mediaKind === "image" || mediaKind === "video"; + +// Returns the legacy media type used by existing preview consumers. +export const getMediaType = ({url,fileName,mimeType}: { url?: string; fileName?: string; mimeType?: string; }): MediaKind => { + const previewKind = getMediaPreviewKind({ url, fileName, mimeType }); + if (previewKind === "video" || previewKind === "audio") { + return previewKind; + } + return "image"; }; + +// Infers the legacy media type from a URL alone. +export const getMediaTypeFromUrl = (url?: string): MediaKind => getMediaType({ url }); diff --git a/src/helpers/mediaUrl.ts b/src/helpers/mediaUrl.ts new file mode 100644 index 00000000..98a87d6f --- /dev/null +++ b/src/helpers/mediaUrl.ts @@ -0,0 +1,99 @@ +import { getSession } from "../adapters/auth"; +import { env } from "../adapters/env"; + +export type ResolvedMediaUrl = { + url: string; + revoke?: () => void; +}; + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ""); +const backendRoot = trimTrailingSlash(env("NEXT_PUBLIC_BACKEND_API_URL")); +const backendApiRoot = backendRoot ? `${backendRoot}/api` : ""; +const protectedMediaPathPattern = /^\/(?:api\/)?media\/[^/]+\/download(?:[/?#]|$)/; + +// Checks whether a URL already includes an absolute protocol and host. +const isAbsoluteUrl = (value: string) => /^[a-z][a-z0-9+.-]*:\/\//i.test(value); + +// Detects whether a media URL points to the signed private download route. +export const isProtectedMediaUrl = (value?: string): boolean => { + if (!value) return false; + + if (protectedMediaPathPattern.test(value)) { + return true; + } + + if (isAbsoluteUrl(value)) { + try { + return protectedMediaPathPattern.test(new URL(value).pathname); + } catch { + return false; + } + } + + return false; +}; + +// Normalizes media paths into absolute browser-safe URLs. +export const getAbsoluteMediaUrl = (value?: string): string => { + if (!value) return ""; + if (value.startsWith("blob:") || value.startsWith("data:")) { + return value; + } + + let resolvedUrl = value; + + if (!isAbsoluteUrl(value)) { + if (!backendRoot) { + return value.startsWith("/") ? value : `/${value.replace(/^\/+/, "")}`; + } + + if (value.startsWith("/api/")) { + resolvedUrl = `${backendRoot}${value}`; + } else if (value.startsWith("/media/")) { + resolvedUrl = `${backendApiRoot}${value}`; + } else if (value.startsWith("/")) { + resolvedUrl = `${backendRoot}${value}`; + } else { + resolvedUrl = `${backendRoot}/${value.replace(/^\/+/, "")}`; + } + } + + return new URL(resolvedUrl).toString(); +}; + +// Opens a media URL in a new tab for direct browser handling. +export const openMediaInNewTab = async (value?: string) => { + if (typeof window === "undefined") { + return; + } + + const url = getAbsoluteMediaUrl(value); + if (!url) { + return; + } + + window.open(url, "_blank", "noopener,noreferrer"); +}; + +// Resolves preview URLs and converts protected media into temporary blob URLs. +export const resolveMediaPreviewUrl = async (value?: string): Promise => { + const absoluteUrl = getAbsoluteMediaUrl(value); + if (!absoluteUrl || !isProtectedMediaUrl(value)) { + return { url: absoluteUrl }; + } + + const session = await getSession(); + const headers = new Headers(); + if (session?.user?.accessToken) { + headers.set("Authorization", `Bearer ${session.user.accessToken}`); + } + + const response = await fetch(absoluteUrl, { headers }); + if (!response.ok) { + throw new Error(`Failed to resolve media preview: ${response.status}`); + } + + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + return { url: objectUrl, revoke: () => URL.revokeObjectURL(objectUrl) }; +};