|
| 1 | +import { maxFabricConnectUploadFileSize, maxUploadFileSize } from '@/config/constants'; |
| 2 | +import { useInstanceClientIdParams } from '@/config/useInstanceClient'; |
| 3 | +import { authStore } from '@/features/auth/store/authStore'; |
| 4 | +import { useDraggingHook } from '@/features/instance/applications/components/ApplicationsSidebar/useDraggingHook'; |
| 5 | +import { isDirectory } from '@/features/instance/applications/context/isDirectory'; |
| 6 | +import { useEditorView } from '@/features/instance/applications/hooks/useEditorView'; |
| 7 | +import { setComponentFile } from '@/integrations/api/instance/applications/setComponentFile'; |
| 8 | +import { humanFileSize } from '@/lib/humanFileSize'; |
| 9 | +import { pluralize } from '@/lib/pluralize'; |
| 10 | +import { readAsDataURL } from '@/lib/storage/readAsDataURL'; |
| 11 | +import { useParams } from '@tanstack/react-router'; |
| 12 | +import { cx } from 'class-variance-authority'; |
| 13 | +import { useCallback, useState } from 'react'; |
| 14 | +import { FileRejection, FileWithPath, useDropzone } from 'react-dropzone'; |
| 15 | +import { toast } from 'sonner'; |
| 16 | + |
| 17 | +export function DropTarget() { |
| 18 | + const [uploading, setUploading] = useState(''); |
| 19 | + const { clusterId }: { clusterId?: string; } = useParams({ strict: false }); |
| 20 | + const { openedEntry, restrictPackageModification, reloadRootEntries, entryExists } = useEditorView(); |
| 21 | + const canUpload = !!openedEntry && !openedEntry.package && !restrictPackageModification; |
| 22 | + const instanceParams = useInstanceClientIdParams(); |
| 23 | + const { dragging: isDraggingAnywhere, dragTarget } = useDraggingHook(); |
| 24 | + const dragTargetId = dragTarget?.getAttribute?.('data-rct-item-id'); |
| 25 | + const dragTargetDirectory = dragTargetId?.split?.('/')?.pop?.(); |
| 26 | + |
| 27 | + const currentProject = openedEntry?.project; |
| 28 | + let currentPath: string | false = false; |
| 29 | + let currentDirectory: string | undefined = undefined; |
| 30 | + if (openedEntry?.path) { |
| 31 | + const parts = openedEntry.path.split('/'); |
| 32 | + const currentPathIsDirectory = isDirectory(openedEntry); |
| 33 | + currentPath = canUpload && (( |
| 34 | + currentPathIsDirectory |
| 35 | + ? parts.slice(1) |
| 36 | + : parts.slice(1, -1) |
| 37 | + ).join('/')); |
| 38 | + currentDirectory = parts[parts.length - (currentPathIsDirectory ? 1 : 2)]; |
| 39 | + } |
| 40 | + |
| 41 | + const onUploadDrop = useCallback(async ( |
| 42 | + rawAcceptedFiles: FileWithPath[], |
| 43 | + rawRejectedFiles: FileRejection[], |
| 44 | + ) => { |
| 45 | + const dragItemId = dragTarget?.getAttribute?.('data-rct-item-id')?.split?.('/'); |
| 46 | + const targetProject = dragItemId?.length ? dragItemId[0] : canUpload ? currentProject : false; |
| 47 | + const targetPath = dragItemId?.length ? dragItemId.slice(1).join('/') : canUpload ? currentPath : false; |
| 48 | + if (targetProject === false || targetProject === undefined || targetPath === false || targetPath === undefined) { |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + const filesToUpload: FileWithPath[] = []; |
| 53 | + const filesRejected: FileRejection[] = rawRejectedFiles.slice(); |
| 54 | + |
| 55 | + for (const file of rawAcceptedFiles) { |
| 56 | + const filePath = getFilePath(targetPath, file); |
| 57 | + if (file.name.startsWith('.') || file.relativePath?.includes('/.')) { |
| 58 | + filesRejected.push({ |
| 59 | + file, |
| 60 | + errors: [ |
| 61 | + { |
| 62 | + message: 'Sensitive files and folders starting with . are skipped.', |
| 63 | + code: 'dot-ignored', |
| 64 | + }, |
| 65 | + ], |
| 66 | + }); |
| 67 | + } else if (entryExists(`${targetProject}/${filePath}`)) { |
| 68 | + filesRejected.push({ |
| 69 | + file, |
| 70 | + errors: [ |
| 71 | + { |
| 72 | + message: `${filePath} already exists`, |
| 73 | + code: 'duplicate', |
| 74 | + }, |
| 75 | + ], |
| 76 | + }); |
| 77 | + } else { |
| 78 | + filesToUpload.push(file); |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + let canceled = false; |
| 83 | + |
| 84 | + const id = 'uploading-files'; |
| 85 | + const toastCancelAction = { |
| 86 | + label: 'Cancel', |
| 87 | + onClick: () => { |
| 88 | + canceled = true; |
| 89 | + }, |
| 90 | + }; |
| 91 | + const toastOKAction = { |
| 92 | + label: 'OK', |
| 93 | + onClick: () => undefined, |
| 94 | + }; |
| 95 | + |
| 96 | + setUploading(`Uploading ${pluralize(filesToUpload.length, 'file', 'files')}...`); |
| 97 | + |
| 98 | + const totalBytes = filesToUpload.reduce((sum, f) => sum + f.size, 0); |
| 99 | + let uploadedBytes = 0; |
| 100 | + let counter = 0; |
| 101 | + for (const file of filesToUpload) { |
| 102 | + counter += 1; |
| 103 | + |
| 104 | + toast.loading(`Upload in progress...`, { |
| 105 | + id, |
| 106 | + descriptionClassName: 'whitespace-pre', |
| 107 | + description: `${counter} of ${pluralize(filesToUpload.length, 'file', 'files')} |
| 108 | +${file.name} |
| 109 | +${humanFileSize(uploadedBytes)} of ${humanFileSize(totalBytes)}`, |
| 110 | + action: toastCancelAction, |
| 111 | + }); |
| 112 | + |
| 113 | + const filePath = getFilePath(targetPath, file); |
| 114 | + const dataURLResponse = await readAsDataURL(file); |
| 115 | + const dataURLResult = dataURLResponse.target!.result as string; |
| 116 | + const demarcation = 'base64,'; |
| 117 | + const encodingIndex = dataURLResult.indexOf(demarcation); |
| 118 | + if (!canceled) { |
| 119 | + await setComponentFile({ |
| 120 | + ...instanceParams, |
| 121 | + file: filePath, |
| 122 | + project: targetProject, |
| 123 | + encoding: 'base64', |
| 124 | + payload: dataURLResult.slice(encodingIndex + demarcation.length), |
| 125 | + }); |
| 126 | + uploadedBytes += file.size; |
| 127 | + } else { |
| 128 | + filesRejected.push({ |
| 129 | + file, |
| 130 | + errors: [ |
| 131 | + { |
| 132 | + message: `${filePath} cancelled`, |
| 133 | + code: 'cancelled', |
| 134 | + }, |
| 135 | + ], |
| 136 | + }); |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + toast.loading(`Reloading sidebar...`, { |
| 141 | + id: canceled ? undefined : id, |
| 142 | + action: toastOKAction, |
| 143 | + description: '', |
| 144 | + }); |
| 145 | + await reloadRootEntries(); |
| 146 | + |
| 147 | + if (filesRejected.length === 0) { |
| 148 | + if (rawAcceptedFiles.length > 0) { |
| 149 | + toast.success(`Uploaded ${pluralize(filesToUpload.length, 'file', 'files')}!`, { |
| 150 | + id, |
| 151 | + action: toastOKAction, |
| 152 | + description: '', |
| 153 | + }); |
| 154 | + } |
| 155 | + } else { |
| 156 | + // Note: this console.log is deliberate. It lets developers know all the files that were rejected. |
| 157 | + console.log(filesRejected); |
| 158 | + toast.error(canceled ? 'Cancelled uploads' : 'Rejected uploads', { |
| 159 | + id: canceled ? undefined : id, |
| 160 | + action: toastOKAction, |
| 161 | + descriptionClassName: 'whitespace-pre overflow-y-auto', |
| 162 | + description: filesRejected |
| 163 | + .slice(0, 5) |
| 164 | + .map(r => r.errors.map(e => e.message).join('\n')) |
| 165 | + .join('\n') |
| 166 | + + (filesRejected?.length > 5 ? '\nCheck the console for the full list.' : ''), |
| 167 | + }); |
| 168 | + } |
| 169 | + setUploading(''); |
| 170 | + }, [dragTarget, canUpload, currentProject, currentPath, entryExists, instanceParams, reloadRootEntries]); |
| 171 | + |
| 172 | + const isFabricConnect = !!clusterId && authStore.checkForFabricConnect(clusterId); |
| 173 | + const { getRootProps, getInputProps } = useDropzone({ |
| 174 | + multiple: true, |
| 175 | + maxSize: isFabricConnect ? maxFabricConnectUploadFileSize : maxUploadFileSize, |
| 176 | + onDrop: onUploadDrop, |
| 177 | + }); |
| 178 | + |
| 179 | + if (!canUpload && !dragTarget) { |
| 180 | + if (!isDraggingAnywhere) { |
| 181 | + return null; |
| 182 | + } |
| 183 | + return ( |
| 184 | + <div |
| 185 | + className={cx( |
| 186 | + 'border-3 border-dashed', |
| 187 | + 'pointer-events-auto', |
| 188 | + 'cursor-copy text-sm text-center', |
| 189 | + 'p-2 w-full fixed bottom-0 h-16', |
| 190 | + 'flex flex-col items-center justify-center', |
| 191 | + 'bg-red-950 border-red-600', |
| 192 | + )}> |
| 193 | + You cannot upload into<br /><strong>{currentDirectory}</strong> |
| 194 | + </div> |
| 195 | + ); |
| 196 | + } |
| 197 | + |
| 198 | + return ( |
| 199 | + <div {...getRootProps()} className={isDraggingAnywhere ? 'fixed top-0 bottom-0 w-full' + |
| 200 | + ' pointer-events-none' : ''}> |
| 201 | + <input id="dropTarget" {...getInputProps()} /> |
| 202 | + <div |
| 203 | + className={cx( |
| 204 | + 'border-3 border-dashed', |
| 205 | + 'pointer-events-auto', |
| 206 | + 'cursor-copy text-sm text-center', |
| 207 | + 'p-2 w-full fixed bottom-0 h-16', |
| 208 | + 'flex flex-col items-center justify-center', |
| 209 | + isDraggingAnywhere |
| 210 | + ? 'animate-glow-pulse bg-green-950 border-green-600' |
| 211 | + : 'bg-purple-950 border-purple-600', |
| 212 | + )}> |
| 213 | + { |
| 214 | + uploading |
| 215 | + ? uploading |
| 216 | + : dragTarget |
| 217 | + ? <>Drop to upload into<br /><strong>{dragTargetDirectory}</strong></> |
| 218 | + : isDraggingAnywhere |
| 219 | + ? <>Drop to upload into<br /><strong>{currentDirectory}</strong></> |
| 220 | + : <>Drag and drop to upload<br />or click to select files.</> |
| 221 | + } |
| 222 | + </div> |
| 223 | + </div> |
| 224 | + ); |
| 225 | +} |
| 226 | + |
| 227 | +function getFilePath(intoPath: string, file: FileWithPath): string { |
| 228 | + const relativePath = file.relativePath ?? file.name; |
| 229 | + const firstSlashIndex = relativePath.indexOf('/'); |
| 230 | + const trimmedRelativePath = firstSlashIndex === 0 || firstSlashIndex === 1 ? relativePath.slice(firstSlashIndex + 1) : relativePath; |
| 231 | + return intoPath ? `${intoPath}/${trimmedRelativePath}` : trimmedRelativePath; |
| 232 | +} |
0 commit comments