BucketFilePickerPreviewPane.tsx191 lines · main
1import { useParams } from 'common'
2import { AlertCircle, LoaderCircle, X } from 'lucide-react'
3import SVG from 'react-inlinesvg'
4import { Button } from 'ui'
5
6import { StorageItemWithColumn } from '../Storage.types'
7import { useFetchFileUrlQuery } from '../StorageExplorer/useFetchFileUrlQuery'
8import { useBucketFilePickerStateSnapshot } from './BucketFilePickerState'
9import { BASE_PATH } from '@/lib/constants'
10import { formatBytes } from '@/lib/helpers'
11
12const PREVIEW_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB
13
14const PreviewFile = ({ item }: { item: StorageItemWithColumn }) => {
15 const { ref: projectRef } = useParams()
16 const { bucket, columns } = useBucketFilePickerStateSnapshot()
17 const path = columns.slice(0, item.columnIndex).concat(item.name).join('/')
18
19 const { data: previewUrl, isPending: isLoading } = useFetchFileUrlQuery({
20 path,
21 projectRef: projectRef!,
22 bucket,
23 })
24
25 // if the size is not available, we set it to be greater than the max size
26 const size = +(item.metadata?.size ?? PREVIEW_SIZE_LIMIT + 1)
27 const mimeType = item.metadata?.mimetype
28
29 const isSkipped = !!mimeType && !!size && size > PREVIEW_SIZE_LIMIT
30
31 if (isLoading) {
32 return (
33 <div className="flex h-full w-full items-center justify-center text-foreground-lighter">
34 <LoaderCircle size={14} strokeWidth={2} className="animate-spin text-foreground-lighter" />
35 </div>
36 )
37 }
38 if (isSkipped) {
39 return (
40 <div className="flex h-full w-full flex-col items-center justify-center">
41 <SVG
42 src={`${BASE_PATH}/img/file-filled.svg`}
43 preProcessor={(code) =>
44 code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
45 }
46 />
47 <p className="mt-2 w-2/5 text-center text-sm">
48 File size is too large to preview in the explorer
49 </p>
50 </div>
51 )
52 }
53 if (!mimeType || !previewUrl) {
54 return (
55 <SVG
56 src={`${BASE_PATH}/img/file-filled.svg`}
57 preProcessor={(code) =>
58 code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
59 }
60 />
61 )
62 }
63
64 if (mimeType.includes('image')) {
65 return (
66 <div
67 className="flex h-full w-full items-center justify-center bg-contain bg-center bg-no-repeat"
68 style={{ backgroundImage: `url('${previewUrl}')` }}
69 />
70 )
71 }
72 if (mimeType.includes('audio')) {
73 return (
74 <div className="flex h-full w-full items-center justify-center px-10">
75 <audio key={previewUrl} controls style={{ width: 'inherit' }}>
76 <source src={previewUrl} type="audio/mpeg" />
77 <p className="text-sm text-foreground-light">
78 Your browser does not support the audio element.
79 </p>
80 </audio>
81 </div>
82 )
83 }
84 if (mimeType.includes('video')) {
85 return (
86 <div className="flex h-full w-full items-center justify-center">
87 <video key={previewUrl} controls style={{ maxHeight: '100%' }}>
88 <source src={previewUrl} type="video/mp4" />
89 <p className="text-sm text-foreground-light">
90 Your browser does not support the video tag.
91 </p>
92 </video>
93 </div>
94 )
95 }
96 return (
97 <SVG
98 src={`${BASE_PATH}/img/file-filled.svg`}
99 preProcessor={(code) =>
100 code.replace(/svg/, 'svg class="mx-auto w-32 h-32 text-color-inherit opacity-75"')
101 }
102 />
103 )
104}
105
106export const PreviewPane = ({ onSelect }: { onSelect: (url: string) => void }) => {
107 const { ref: projectRef } = useParams()
108 const {
109 selectedFilePreview: file,
110 setSelectedFilePreview,
111 bucket,
112 columns,
113 } = useBucketFilePickerStateSnapshot()
114
115 const path = file ? columns.slice(0, file.columnIndex).concat(file.name).join('/') : ''
116 const { data: previewUrl, isLoading } = useFetchFileUrlQuery(
117 { path, projectRef: projectRef!, bucket },
118 { enabled: !!file }
119 )
120
121 if (!file) return null
122
123 const size = file.metadata ? formatBytes(file.metadata.size) : null
124 const mimeType = file.metadata ? file.metadata.mimetype : undefined
125 const createdAt = file.created_at ? new Date(file.created_at).toLocaleString() : 'Unknown'
126 const updatedAt = file.updated_at ? new Date(file.updated_at).toLocaleString() : 'Unknown'
127
128 return (
129 <div className="h-full border-l border-overlay bg-surface-100 overflow-y-auto w-[450px] pb-4">
130 {/* Preview Header */}
131 <div className="flex w-full justify-end items-center gap-2 sticky top-0 bg-surface-100 p-4 border-b">
132 <Button
133 size="tiny"
134 onClick={() => onSelect(previewUrl!)}
135 disabled={!previewUrl}
136 loading={isLoading}
137 >
138 Select
139 </Button>
140 <div className="text-foreground-lighter transition-colors hover:text-foreground">
141 <X
142 className="cursor-pointer"
143 size={14}
144 strokeWidth={2}
145 onClick={() => setSelectedFilePreview(undefined)}
146 />
147 </div>
148 </div>
149
150 {/* Preview Thumbnail*/}
151 <div className="my-4 border border-overlay mx-4">
152 <div className="flex h-56 w-full items-center 2xl:h-72">
153 <PreviewFile item={file} />
154 </div>
155 </div>
156
157 <div className="w-full space-y-6 px-4">
158 {/* Preview Information */}
159 <div className="space-y-1">
160 <h5 className="wrap-break-word text-base text-foreground">{file.name}</h5>
161 {file.isCorrupted && (
162 <div className="flex items-center space-x-2">
163 <AlertCircle size={14} strokeWidth={2} className="text-foreground-light" />
164 <p className="text-sm text-foreground-light">
165 File is corrupted, please delete and reupload this file again
166 </p>
167 </div>
168 )}
169 {mimeType && (
170 <p className="text-sm text-foreground-light">
171 {mimeType}
172 {size && <span> - {size}</span>}
173 </p>
174 )}
175 </div>
176
177 {/* Preview Metadata */}
178 <div className="space-y-2">
179 <div>
180 <label className="mb-1 text-xs text-foreground-lighter">Added on</label>
181 <p className="text-sm text-foreground-light">{createdAt}</p>
182 </div>
183 <div>
184 <label className="mb-1 text-xs text-foreground-lighter">Last modified</label>
185 <p className="text-sm text-foreground-light">{updatedAt}</p>
186 </div>
187 </div>
188 </div>
189 </div>
190 )
191}