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