11'use client'
22
3- import { useState } from 'react'
3+ import { useState , useEffect , useMemo , useRef } from 'react'
44import { useRouter } from 'next/navigation'
55import { Button } from '@/components/ui/button'
66import { Input } from '@/components/ui/input'
7- import { ExternalLink , Play , Download } from 'lucide-react'
7+ import { ExternalLink , Play , Download , AlertCircle } from 'lucide-react'
8+ import { API_PREFIX } from '@/lib/utils/urlUtils'
9+ import { debounce } from 'lodash'
10+
11+ type PathType = 's3' | 'local' | 'invalid' | 'empty'
12+
13+ function getPathType ( path : string , isLocal : boolean ) : PathType {
14+ const trimmed = path . trim ( )
15+ if ( ! trimmed ) return 'empty'
16+ // Match partial s3:// prefix as user is typing
17+ if ( trimmed . startsWith ( 's3://' ) || 's3://' . startsWith ( trimmed ) ) return 's3'
18+ if ( trimmed . startsWith ( '/' ) || trimmed . startsWith ( '~' ) ) {
19+ return isLocal ? 'local' : 'invalid'
20+ }
21+ return 'invalid'
22+ }
823
924export default function HomeInfoSection ( ) {
1025 const router = useRouter ( )
1126 const [ folderPath , setFolderPath ] = useState ( '' )
27+ const [ isLocal , setIsLocal ] = useState ( false )
28+ const [ suggestions , setSuggestions ] = useState < string [ ] > ( [ ] )
29+ const [ showSuggestions , setShowSuggestions ] = useState ( false )
30+ const [ selectedIndex , setSelectedIndex ] = useState ( - 1 )
31+ const inputRef = useRef < HTMLInputElement > ( null )
32+ const suggestionsRef = useRef < HTMLDivElement > ( null )
33+
34+ useEffect ( ( ) => {
35+ const url = window . location . href
36+ setIsLocal ( url . startsWith ( 'http://localhost' ) || url . startsWith ( 'http://127.0.0.1' ) )
37+ } , [ ] )
38+
39+ // Close suggestions when clicking outside
40+ useEffect ( ( ) => {
41+ const handleClickOutside = ( e : MouseEvent ) => {
42+ if (
43+ inputRef . current &&
44+ ! inputRef . current . contains ( e . target as Node ) &&
45+ suggestionsRef . current &&
46+ ! suggestionsRef . current . contains ( e . target as Node )
47+ ) {
48+ setShowSuggestions ( false )
49+ }
50+ }
51+ document . addEventListener ( 'mousedown' , handleClickOutside )
52+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside )
53+ } , [ ] )
54+
55+ const fetchSuggestions = useMemo (
56+ ( ) =>
57+ debounce ( async ( path : string , local : boolean ) => {
58+ const pathType = getPathType ( path , local )
59+ if ( pathType === 'empty' || pathType === 'invalid' ) {
60+ setSuggestions ( [ ] )
61+ return
62+ }
63+
64+ try {
65+ const endpoint = pathType === 's3'
66+ ? `${ API_PREFIX } /s3-typeahead`
67+ : `${ API_PREFIX } /typeahead`
68+
69+ const response = await fetch ( `${ endpoint } ?path=${ encodeURIComponent ( path ) } ` )
70+ if ( response . ok ) {
71+ const data = await response . json ( )
72+ setSuggestions ( data )
73+ setShowSuggestions ( data . length > 0 )
74+ setSelectedIndex ( - 1 )
75+ }
76+ } catch {
77+ setSuggestions ( [ ] )
78+ }
79+ } , 300 ) ,
80+ [ ]
81+ )
1282
1383 const handleOpenUrl = ( url : string ) => {
1484 if ( typeof window !== 'undefined' ) {
@@ -22,31 +92,102 @@ export default function HomeInfoSection() {
2292 }
2393 }
2494
25- const handleKeyPress = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
95+ const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
96+ const value = e . target . value
97+ setFolderPath ( value )
98+ fetchSuggestions ( value , isLocal )
99+ }
100+
101+ const handleSelectSuggestion = ( suggestion : string ) => {
102+ setFolderPath ( suggestion )
103+ setShowSuggestions ( false )
104+ setSuggestions ( [ ] )
105+ // Fetch new suggestions for the selected path
106+ fetchSuggestions ( suggestion , isLocal )
107+ inputRef . current ?. focus ( )
108+ }
109+
110+ const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
26111 if ( e . key === 'Enter' ) {
27- handleGoToFolder ( )
112+ if ( selectedIndex >= 0 && suggestions [ selectedIndex ] ) {
113+ handleSelectSuggestion ( suggestions [ selectedIndex ] )
114+ } else {
115+ handleGoToFolder ( )
116+ }
117+ } else if ( e . key === 'ArrowDown' ) {
118+ e . preventDefault ( )
119+ setSelectedIndex ( prev => Math . min ( prev + 1 , suggestions . length - 1 ) )
120+ } else if ( e . key === 'ArrowUp' ) {
121+ e . preventDefault ( )
122+ setSelectedIndex ( prev => Math . max ( prev - 1 , - 1 ) )
123+ } else if ( e . key === 'Escape' ) {
124+ setShowSuggestions ( false )
125+ } else if ( e . key === 'Tab' && showSuggestions && suggestions . length > 0 ) {
126+ e . preventDefault ( )
127+ const idx = selectedIndex >= 0 ? selectedIndex : 0
128+ handleSelectSuggestion ( suggestions [ idx ] )
28129 }
29130 }
30131
132+ const pathType = getPathType ( folderPath , isLocal )
133+ const showError = pathType === 'invalid'
134+
31135 return (
32136 < div className = "max-w-4xl w-full mb-12" >
33137 < h2 className = "text-xl font-semibold text-foreground mb-6" >
34- Browse local or s3 folders
138+ { isLocal ? ' Browse local or S3 folders' : 'Browse S3 folders' }
35139 </ h2 >
36140
37- < div className = "flex gap-2 mb-8" >
38- < Input
39- type = "text"
40- placeholder = "Enter folder path (e.g., /tmp/folder, ~/Downloads or s3://bucket/path)"
41- value = { folderPath }
42- onChange = { ( e ) => setFolderPath ( e . target . value ) }
43- onKeyPress = { handleKeyPress }
44- className = "flex-1"
45- />
46- < Button onClick = { handleGoToFolder } disabled = { ! folderPath . trim ( ) } >
141+ < div className = "flex gap-2 mb-2" >
142+ < div className = "relative flex-1" >
143+ < Input
144+ ref = { inputRef }
145+ type = "text"
146+ placeholder = { isLocal
147+ ? "Enter folder path (e.g., /tmp/folder, ~/Downloads or s3://bucket/path)"
148+ : "Enter S3 path (e.g., s3://bucket/path)"
149+ }
150+ value = { folderPath }
151+ onChange = { handleInputChange }
152+ onKeyDown = { handleKeyDown }
153+ onFocus = { ( ) => suggestions . length > 0 && setShowSuggestions ( true ) }
154+ className = { `font-mono ${ showError ? 'border-red-500' : '' } ` }
155+ />
156+ { showSuggestions && suggestions . length > 0 && (
157+ < div
158+ ref = { suggestionsRef }
159+ className = "absolute z-50 w-full mt-1 bg-background border border-border rounded-md shadow-lg max-h-60 overflow-auto"
160+ >
161+ { suggestions . map ( ( suggestion , index ) => (
162+ < div
163+ key = { suggestion }
164+ className = { `px-3 py-2 cursor-pointer text-sm font-mono truncate ${
165+ index === selectedIndex
166+ ? 'bg-accent text-accent-foreground'
167+ : 'hover:bg-accent/50'
168+ } `}
169+ onClick = { ( ) => handleSelectSuggestion ( suggestion ) }
170+ >
171+ { suggestion }
172+ </ div >
173+ ) ) }
174+ </ div >
175+ ) }
176+ </ div >
177+ < Button onClick = { handleGoToFolder } disabled = { ! folderPath . trim ( ) || showError } >
47178 Go
48179 </ Button >
49180 </ div >
181+ { showError && (
182+ < div className = "flex items-center gap-2 text-red-500 text-sm mb-6" >
183+ < AlertCircle className = "h-4 w-4" />
184+ { isLocal
185+ ? 'Path must start with /, ~, or s3://'
186+ : 'Path must start with s3:// (local paths not available on cloud)'
187+ }
188+ </ div >
189+ ) }
190+ { ! showError && < div className = "mb-6" /> }
50191
51192 < h2 className = "text-xl font-semibold text-foreground mb-6" >
52193 Learn more
0 commit comments