StorageSearchResults.tsx 9.7 KB

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