StorageSearchResults.tsx325 lines · main
1'use client'
2
3import { useDebounce } from '@uidotdev/usehooks'
4import { useParams } from 'common'
5import { AnalyticsBucket as AnalyticsBucketIcon, FilesBucket, Storage, VectorBucket } from 'icons'
6import { Loader2 } from 'lucide-react'
7import { useCallback, useMemo } from 'react'
8
9import {
10 EmptyState,
11 ResultsList,
12 SkeletonResults,
13 type SearchResult,
14} from './ContextSearchResults.shared'
15import {
16 useIsAnalyticsBucketsEnabled,
17 useIsVectorBucketsEnabled,
18} from '@/data/config/project-storage-config-query'
19import {
20 useAnalyticsBucketsQuery,
21 type AnalyticsBucket,
22} from '@/data/storage/analytics-buckets-query'
23import {
24 useBucketNumberEstimateQuery,
25 usePaginatedBucketsQuery,
26 type Bucket,
27} from '@/data/storage/buckets-query'
28import { useVectorBucketsQuery } from '@/data/storage/vector-buckets-query'
29
30interface StorageSearchResultsProps {
31 query: string
32}
33
34type ExtendedSearchResult = SearchResult & {
35 bucketType?: 'file' | 'analytics' | 'vector'
36 bucket?: unknown
37}
38
39function filterBuckets<T>(
40 buckets: T[] | null | undefined,
41 query: string,
42 filterFn: (bucket: T, searchLower: string) => boolean,
43 mapFn: (bucket: T) => ExtendedSearchResult
44): ExtendedSearchResult[] {
45 if (!buckets) return []
46
47 const trimmedQuery = query.trim()
48 const filtered = trimmedQuery
49 ? buckets.filter((bucket) => filterFn(bucket, trimmedQuery.toLowerCase()))
50 : buckets
51
52 return filtered.slice(0, 10).map(mapFn)
53}
54
55export function StorageSearchResults({ query }: StorageSearchResultsProps) {
56 const { ref: projectRef } = useParams()
57
58 const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef })
59 const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef })
60
61 // Debounce the search query to avoid excessive API calls
62 const debouncedQuery = useDebounce(query.trim(), 300)
63
64 const {
65 data: fileBucketsData,
66 isLoading: isLoadingFileBuckets,
67 isError: isErrorFileBuckets,
68 } = usePaginatedBucketsQuery(
69 {
70 projectRef: projectRef ?? undefined,
71 limit: 10,
72 search: debouncedQuery.length > 0 ? debouncedQuery : undefined,
73 },
74 {
75 enabled: !!projectRef,
76 }
77 )
78 const fileBuckets = useMemo(
79 () => fileBucketsData?.pages.flatMap((page) => page) ?? [],
80 [fileBucketsData]
81 )
82
83 const {
84 data: analyticsBuckets,
85 isLoading: isLoadingAnalyticsBuckets,
86 isError: isErrorAnalyticsBuckets,
87 } = useAnalyticsBucketsQuery(
88 {
89 projectRef: projectRef ?? undefined,
90 },
91 {
92 enabled: !!projectRef && isAnalyticsBucketsEnabled,
93 }
94 )
95
96 const {
97 data: vectorBucketsData,
98 isLoading: isLoadingVectorBuckets,
99 isError: isErrorVectorBuckets,
100 } = useVectorBucketsQuery(
101 {
102 projectRef: projectRef ?? undefined,
103 },
104 {
105 enabled: !!projectRef && isVectorBucketsEnabled,
106 }
107 )
108
109 const vectorBuckets = useMemo(() => vectorBucketsData?.vectorBuckets ?? [], [vectorBucketsData])
110
111 const { data: fileBucketsEstimate } = useBucketNumberEstimateQuery({
112 projectRef,
113 })
114
115 const isLoading =
116 isLoadingFileBuckets ||
117 (isAnalyticsBucketsEnabled && isLoadingAnalyticsBuckets) ||
118 (isVectorBucketsEnabled && isLoadingVectorBuckets)
119 const isError =
120 isErrorFileBuckets ||
121 (isAnalyticsBucketsEnabled && isErrorAnalyticsBuckets) ||
122 (isVectorBucketsEnabled && isErrorVectorBuckets)
123
124 const fileBucketResults: ExtendedSearchResult[] = useMemo(() => {
125 // Server-side search is already applied, no need for client-side filtering
126 if (!fileBuckets) return []
127
128 return fileBuckets.map((bucket) => {
129 const displayName = bucket.name || bucket.id || 'Untitled Bucket'
130 const visibility = bucket.public ? 'Public' : 'Private'
131 const description = `File bucket • ${visibility}`
132
133 return {
134 id: `file-bucket-${bucket.id || bucket.name}`,
135 name: displayName,
136 description,
137 bucketType: 'file' as const,
138 bucket,
139 }
140 })
141 }, [fileBuckets])
142
143 const analyticsBucketResults: ExtendedSearchResult[] = useMemo(() => {
144 return filterBuckets(
145 analyticsBuckets,
146 debouncedQuery, // Use debounced query for consistency
147 (bucket, searchLower) => {
148 const bucketName = bucket.name?.toLowerCase() || ''
149 return bucketName.includes(searchLower)
150 },
151 (bucket) => {
152 const displayName = bucket.name || 'Untitled Bucket'
153 const description = 'Analytics bucket'
154
155 return {
156 id: `analytics-bucket-${bucket.name}`,
157 name: displayName,
158 description,
159 bucketType: 'analytics' as const,
160 bucket,
161 }
162 }
163 )
164 }, [analyticsBuckets, debouncedQuery])
165
166 const vectorBucketResults: ExtendedSearchResult[] = useMemo(() => {
167 return filterBuckets(
168 vectorBuckets,
169 debouncedQuery, // Use debounced query for consistency
170 (bucket, searchLower) => {
171 const bucketName = bucket.vectorBucketName?.toLowerCase() || ''
172 return bucketName.includes(searchLower)
173 },
174 (bucket) => {
175 const displayName = bucket.vectorBucketName || 'Untitled Bucket'
176 const description = 'Vector bucket'
177
178 return {
179 id: `vector-bucket-${bucket.vectorBucketName}`,
180 name: displayName,
181 description,
182 bucketType: 'vector' as const,
183 bucket,
184 }
185 }
186 )
187 }, [vectorBuckets, debouncedQuery])
188
189 const allResults: ExtendedSearchResult[] = useMemo(() => {
190 const results = [fileBucketResults]
191 if (isAnalyticsBucketsEnabled) {
192 results.push(analyticsBucketResults)
193 }
194 if (isVectorBucketsEnabled) {
195 results.push(vectorBucketResults)
196 }
197 return results.flat().slice(0, 20)
198 }, [
199 fileBucketResults,
200 analyticsBucketResults,
201 vectorBucketResults,
202 isAnalyticsBucketsEnabled,
203 isVectorBucketsEnabled,
204 ])
205
206 const getRoute = useCallback(
207 (result: SearchResult) => {
208 if (!projectRef) return '/storage/files' as `/${string}`
209
210 const extendedResult = result as ExtendedSearchResult
211
212 if (extendedResult.bucketType && extendedResult.bucket) {
213 const bucketType = extendedResult.bucketType
214
215 if (bucketType === 'file') {
216 const fileBucket = extendedResult.bucket as Bucket
217 return `/project/${projectRef}/storage/files/buckets/${encodeURIComponent(fileBucket.name)}` as `/${string}`
218 }
219
220 if (bucketType === 'analytics') {
221 const analyticsBucket = extendedResult.bucket as AnalyticsBucket
222 return `/project/${projectRef}/storage/analytics/buckets/${encodeURIComponent(analyticsBucket.name)}` as `/${string}`
223 }
224
225 if (bucketType === 'vector') {
226 const vectorBucket = extendedResult.bucket as { vectorBucketName: string }
227 return `/project/${projectRef}/storage/vectors/buckets/${encodeURIComponent(vectorBucket.vectorBucketName)}` as `/${string}`
228 }
229 }
230
231 return `/project/${projectRef}/storage/files` as `/${string}`
232 },
233 [projectRef]
234 )
235
236 const totalBucketsEstimate = useMemo(() => {
237 const fileBucketCount = fileBucketsEstimate ?? 0
238 const analyticsBucketCount = isAnalyticsBucketsEnabled ? (analyticsBuckets?.length ?? 0) : 0
239 const vectorBucketCount = isVectorBucketsEnabled ? (vectorBuckets?.length ?? 0) : 0
240
241 return fileBucketCount + analyticsBucketCount + vectorBucketCount
242 }, [
243 fileBucketsEstimate,
244 analyticsBuckets?.length,
245 vectorBuckets?.length,
246 isAnalyticsBucketsEnabled,
247 isVectorBucketsEnabled,
248 ])
249
250 const getIcon = useCallback((result: SearchResult) => {
251 const extendedResult = result as ExtendedSearchResult
252 if (extendedResult.bucketType === 'file') return FilesBucket
253 if (extendedResult.bucketType === 'analytics') return AnalyticsBucketIcon
254 if (extendedResult.bucketType === 'vector') return VectorBucket
255 return Storage
256 }, [])
257
258 const renderFooter = () => (
259 <div className="absolute bottom-0 left-0 right-0 flex items-center justify-between min-h-9 h-9 px-4 border-t bg-surface-200 text-xs text-foreground-light z-10">
260 <div className="flex items-center gap-x-2">
261 {isLoading ? (
262 <span className="flex items-center gap-2">
263 <Loader2 size={14} className="animate-spin" /> Loading...
264 </span>
265 ) : (
266 <span>
267 Total: {totalBucketsEstimate.toLocaleString()} bucket
268 {totalBucketsEstimate !== 1 ? 's' : ''} (estimate)
269 </span>
270 )}
271 </div>
272 </div>
273 )
274
275 if (isLoading) {
276 return (
277 <div className="relative h-full flex flex-col">
278 <div className="flex-1 min-h-0 overflow-hidden">
279 <SkeletonResults />
280 </div>
281 {renderFooter()}
282 </div>
283 )
284 }
285
286 if (isError) {
287 return (
288 <div className="relative h-full flex flex-col">
289 <div className="flex-1 min-h-0 overflow-hidden">
290 <div className="h-full flex flex-col items-center justify-center py-12 px-4 gap-4 text-center text-foreground-lighter">
291 <Storage className="h-6 w-6" strokeWidth={1.5} />
292 <p className="text-sm">Failed to load storage buckets</p>
293 </div>
294 </div>
295 {renderFooter()}
296 </div>
297 )
298 }
299
300 if (allResults.length === 0) {
301 return (
302 <div className="relative h-full flex flex-col">
303 <div className="flex-1 min-h-0 overflow-hidden">
304 <EmptyState icon={Storage} label="Storage" query={debouncedQuery} />
305 </div>
306 {renderFooter()}
307 </div>
308 )
309 }
310
311 return (
312 <div className="relative h-full flex flex-col">
313 <div className="flex-1 min-h-0 overflow-hidden">
314 <ResultsList
315 results={allResults}
316 icon={Storage}
317 getIcon={getIcon}
318 getRoute={getRoute}
319 className="pb-9"
320 />
321 </div>
322 {renderFooter()}
323 </div>
324 )
325}