PreviewPane.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { AlertCircle, ChevronDown, Copy, Download, LoaderCircle, Trash2, X } from 'lucide-react'
  3. import SVG from 'react-inlinesvg'
  4. import {
  5. Button,
  6. DropdownMenu,
  7. DropdownMenuContent,
  8. DropdownMenuItem,
  9. DropdownMenuTrigger,
  10. } from 'ui'
  11. import { URL_EXPIRY_DURATION } from '../Storage.constants'
  12. import { StorageItem } from '../Storage.types'
  13. import { getPathAlongOpenedFolders } from './StorageExplorer.utils'
  14. import { useCopyUrl } from './useCopyUrl'
  15. import { useFetchFileUrlQuery } from './useFetchFileUrlQuery'
  16. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  17. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  18. import { BASE_PATH } from '@/lib/constants'
  19. import { formatBytes } from '@/lib/helpers'
  20. import { useStorageExplorerStateSnapshot } from '@/state/storage-explorer'
  21. const PREVIEW_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB
  22. const PreviewFile = ({ item }: { item: StorageItem }) => {
  23. const { projectRef, selectedBucket, openedFolders } = useStorageExplorerStateSnapshot()
  24. const folderPath = getPathAlongOpenedFolders({ openedFolders, selectedBucket }, false)
  25. const path = [folderPath, item.name].filter(Boolean).join('/')
  26. const { data: previewUrl, isPending: isLoading } = useFetchFileUrlQuery({
  27. path,
  28. projectRef: projectRef,
  29. bucket: selectedBucket,
  30. })
  31. // if the size is not available, we set it to be greater than the max size
  32. const size = +(item.metadata?.size ?? PREVIEW_SIZE_LIMIT + 1)
  33. const mimeType = item.metadata?.mimetype
  34. const isSkipped = !!mimeType && !!size && size > PREVIEW_SIZE_LIMIT
  35. if (isLoading) {
  36. return (
  37. <div className="flex h-full w-full items-center justify-center text-foreground-lighter">
  38. <LoaderCircle size={14} strokeWidth={2} className="animate-spin text-foreground-lighter" />
  39. </div>
  40. )
  41. }
  42. if (isSkipped) {
  43. return (
  44. <div className="flex h-full w-full flex-col items-center justify-center">
  45. <SVG
  46. src={`${BASE_PATH}/img/file-filled.svg`}
  47. preProcessor={(code) =>
  48. code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
  49. }
  50. />
  51. <p className="mt-2 w-2/5 text-center text-sm">
  52. File size is too large to preview in the explorer
  53. </p>
  54. </div>
  55. )
  56. }
  57. if (!mimeType || !previewUrl) {
  58. return (
  59. <SVG
  60. src={`${BASE_PATH}/img/file-filled.svg`}
  61. preProcessor={(code) =>
  62. code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
  63. }
  64. />
  65. )
  66. }
  67. if (mimeType.includes('image')) {
  68. return (
  69. <div
  70. className="flex h-full w-full items-center justify-center bg-contain bg-center bg-no-repeat"
  71. style={{ backgroundImage: `url('${previewUrl}')` }}
  72. />
  73. )
  74. }
  75. if (mimeType.includes('audio')) {
  76. return (
  77. <div className="flex h-full w-full items-center justify-center px-10">
  78. <audio key={previewUrl} controls style={{ width: 'inherit' }}>
  79. <source src={previewUrl} type="audio/mpeg" />
  80. <p className="text-sm text-foreground-light">
  81. Your browser does not support the audio element.
  82. </p>
  83. </audio>
  84. </div>
  85. )
  86. }
  87. if (mimeType.includes('video')) {
  88. return (
  89. <div className="flex h-full w-full items-center justify-center">
  90. <video key={previewUrl} controls style={{ maxHeight: '100%' }}>
  91. <source src={previewUrl} type="video/mp4" />
  92. <p className="text-sm text-foreground-light">
  93. Your browser does not support the video tag.
  94. </p>
  95. </video>
  96. </div>
  97. )
  98. }
  99. return (
  100. <SVG
  101. src={`${BASE_PATH}/img/file-filled.svg`}
  102. preProcessor={(code) =>
  103. code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
  104. }
  105. />
  106. )
  107. }
  108. export const PreviewPane = () => {
  109. const {
  110. selectedBucket,
  111. selectedFilePreview: file,
  112. setSelectedItemsToDelete,
  113. setSelectedFilePreview,
  114. setSelectedFileCustomExpiry,
  115. downloadFile,
  116. } = useStorageExplorerStateSnapshot()
  117. const { onCopyUrl } = useCopyUrl()
  118. const { can: canUpdateFiles } = useAsyncCheckPermissions(PermissionAction.STORAGE_WRITE, '*')
  119. if (!file) return null
  120. const width = 450
  121. const size = file.metadata ? formatBytes(file.metadata.size) : null
  122. const mimeType = file.metadata ? file.metadata.mimetype : undefined
  123. const createdAt = file.created_at ? new Date(file.created_at).toLocaleString() : 'Unknown'
  124. const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown'
  125. return (
  126. <div
  127. className="h-full border-l border-overlay bg-surface-100 p-4 overflow-y-auto"
  128. style={{ width }}
  129. >
  130. {/* Preview Header */}
  131. <div className="flex w-full justify-end text-foreground-lighter transition-colors hover:text-foreground">
  132. <X
  133. className="cursor-pointer"
  134. size={14}
  135. strokeWidth={2}
  136. onClick={() => setSelectedFilePreview(undefined)}
  137. />
  138. </div>
  139. {/* Preview Thumbnail*/}
  140. <div className="my-4 border border-overlay">
  141. <div className="flex h-56 w-full items-center 2xl:h-72">
  142. <PreviewFile item={file} />
  143. </div>
  144. </div>
  145. <div className="w-full space-y-6">
  146. {/* Preview Information */}
  147. <div className="space-y-1">
  148. <h5 className="wrap-break-word text-base text-foreground">{file.name}</h5>
  149. {file.isCorrupted && (
  150. <div className="flex items-center space-x-2">
  151. <AlertCircle size={14} strokeWidth={2} className="text-foreground-light" />
  152. <p className="text-sm text-foreground-light">
  153. File is corrupted, please delete and reupload this file again
  154. </p>
  155. </div>
  156. )}
  157. {mimeType && (
  158. <p className="text-sm text-foreground-light">
  159. {mimeType}
  160. {size && <span> - {size}</span>}
  161. </p>
  162. )}
  163. </div>
  164. {/* Preview Metadata */}
  165. <div className="space-y-2">
  166. <div>
  167. <label className="mb-1 text-xs text-foreground-lighter">Added on</label>
  168. <p className="text-sm text-foreground-light">{createdAt}</p>
  169. </div>
  170. <div>
  171. <label className="mb-1 text-xs text-foreground-lighter">Last modified</label>
  172. <p className="text-sm text-foreground-light">{updatedAt}</p>
  173. </div>
  174. </div>
  175. {/* Actions */}
  176. <div className="flex space-x-2 border-b border-overlay pb-4">
  177. <Button
  178. type="default"
  179. icon={<Download />}
  180. disabled={file.isCorrupted}
  181. onClick={() => downloadFile(file)}
  182. >
  183. Download
  184. </Button>
  185. {selectedBucket.public ? (
  186. <Button
  187. type="outline"
  188. icon={<Copy />}
  189. onClick={() => onCopyUrl(file.path!)}
  190. disabled={file.isCorrupted}
  191. >
  192. Get URL
  193. </Button>
  194. ) : (
  195. <DropdownMenu>
  196. <DropdownMenuTrigger asChild>
  197. <Button
  198. type="outline"
  199. icon={<Copy />}
  200. iconRight={<ChevronDown />}
  201. disabled={file.isCorrupted}
  202. >
  203. Get URL
  204. </Button>
  205. </DropdownMenuTrigger>
  206. <DropdownMenuContent side="bottom" align="center">
  207. <DropdownMenuItem
  208. key="expires-one-week"
  209. onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.WEEK)}
  210. >
  211. Expire in 1 week
  212. </DropdownMenuItem>
  213. <DropdownMenuItem
  214. key="expires-one-month"
  215. onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.MONTH)}
  216. >
  217. Expire in 1 month
  218. </DropdownMenuItem>
  219. <DropdownMenuItem
  220. key="expires-one-year"
  221. onClick={() => onCopyUrl(file.path!, URL_EXPIRY_DURATION.YEAR)}
  222. >
  223. Expire in 1 year
  224. </DropdownMenuItem>
  225. <DropdownMenuItem
  226. key="custom-expiry"
  227. onClick={() => setSelectedFileCustomExpiry(file)}
  228. >
  229. Custom expiry
  230. </DropdownMenuItem>
  231. </DropdownMenuContent>
  232. </DropdownMenu>
  233. )}
  234. </div>
  235. <ButtonTooltip
  236. type="outline"
  237. disabled={!canUpdateFiles}
  238. size="tiny"
  239. icon={<Trash2 strokeWidth={2} />}
  240. onClick={() => setSelectedItemsToDelete([file])}
  241. tooltip={{
  242. content: {
  243. side: 'bottom',
  244. text: !canUpdateFiles
  245. ? 'You need additional permissions to delete this file'
  246. : undefined,
  247. },
  248. }}
  249. >
  250. Delete file
  251. </ButtonTooltip>
  252. </div>
  253. </div>
  254. )
  255. }