@@ -12,6 +12,8 @@ import { ExternalLink } from 'lucide-react'
1212import { Badge } from '@/components/ui/badge'
1313import { cn } from '@/lib/utils'
1414import useDirDetection from '@/hooks/use-dir-detection'
15+ import { Tabs , TabsList , TabsTrigger , TabsContent } from '@/components/ui/tabs'
16+ import { Input } from '@/components/ui/input'
1517
1618interface UpdateCoreDialogProps {
1719 node : NodeResponse
@@ -23,6 +25,9 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC
2325 const { t } = useTranslation ( )
2426 const dir = useDirDetection ( )
2527 const [ selectedVersion , setSelectedVersion ] = useState < string > ( 'latest' )
28+ const [ customVersion , setCustomVersion ] = useState < string > ( '' )
29+ const [ versionMode , setVersionMode ] = useState < 'list' | 'custom' > ( 'list' )
30+ const [ customVersionError , setCustomVersionError ] = useState < string > ( '' )
2631 const updateCoreMutation = useUpdateCore ( )
2732 const { latestVersion, releaseUrl, versions, isLoading : isLoadingReleases , hasUpdate } = useXrayReleases ( )
2833
@@ -32,22 +37,57 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC
3237 React . useEffect ( ( ) => {
3338 if ( isOpen ) {
3439 setSelectedVersion ( 'latest' )
40+ setCustomVersion ( '' )
41+ setVersionMode ( 'list' )
42+ setCustomVersionError ( '' )
3543 }
3644 } , [ isOpen ] )
3745
46+ const validateCustomVersion = ( version : string ) : boolean => {
47+ if ( ! version . trim ( ) ) {
48+ setCustomVersionError ( t ( 'nodeModal.customVersionRequired' , { defaultValue : 'Version is required' } ) )
49+ return false
50+ }
51+ // Allow versions with or without 'v' prefix, and basic semantic versioning pattern
52+ const versionPattern = / ^ v ? \d + \. \d + \. \d + ( - [ \w . ] + ) ? $ /
53+ const cleanVersion = version . trim ( )
54+ if ( ! versionPattern . test ( cleanVersion ) ) {
55+ setCustomVersionError ( t ( 'nodeModal.invalidVersionFormat' , { defaultValue : 'Invalid version format. Expected: vX.X.X or X.X.X' } ) )
56+ return false
57+ }
58+ setCustomVersionError ( '' )
59+ return true
60+ }
61+
62+ const handleCustomVersionChange = ( value : string ) => {
63+ setCustomVersion ( value )
64+ if ( customVersionError ) {
65+ validateCustomVersion ( value )
66+ }
67+ }
68+
3869 const handleUpdate = async ( ) => {
3970 try {
40- let versionToSend = selectedVersion
41- if ( selectedVersion === 'latest' ) {
42- if ( ! latestVersion ) {
43- toast . error ( t ( 'nodeModal.updateCoreFailed' , {
44- message : 'Latest version not available' ,
45- defaultValue : 'Failed to update Xray core: Latest version not available' ,
46- } ) )
71+ let versionToSend : string
72+
73+ if ( versionMode === 'custom' ) {
74+ if ( ! validateCustomVersion ( customVersion ) ) {
4775 return
4876 }
49- // Use actual latest version instead of 'latest' string
50- versionToSend = latestVersion
77+ versionToSend = customVersion . trim ( )
78+ } else {
79+ versionToSend = selectedVersion
80+ if ( selectedVersion === 'latest' ) {
81+ if ( ! latestVersion ) {
82+ toast . error ( t ( 'nodeModal.updateCoreFailed' , {
83+ message : 'Latest version not available' ,
84+ defaultValue : 'Failed to update Xray core: Latest version not available' ,
85+ } ) )
86+ return
87+ }
88+ // Use actual latest version instead of 'latest' string
89+ versionToSend = latestVersion
90+ }
5191 }
5292
5393 // Ensure version has 'v' prefix for backend pattern vX.X.X
@@ -133,75 +173,111 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC
133173 </ div >
134174
135175 { /* Version Selection */ }
136- < div className = "space-y-2" >
137- < label className = { cn ( 'text-sm font-medium' , dir === 'rtl' && 'text-right' ) } >
138- { t ( 'nodeModal.selectVersion' , { defaultValue : 'Select Version' } ) }
139- </ label >
140- { isLoadingReleases ? (
141- < div className = { cn ( 'rounded-md border p-8 text-center' , dir === 'rtl' && 'text-right' ) } >
142- < div className = "text-sm text-muted-foreground" >
143- { t ( 'nodeModal.loadingReleases' , { defaultValue : 'Loading releases...' } ) }
144- </ div >
145- </ div >
146- ) : (
147- < ScrollArea className = "h-[200px] rounded-md border sm:h-[280px]" >
148- < div className = "p-2 space-y-1" >
149- { latestVersion && (
150- < button
151- type = "button"
152- onClick = { ( ) => setSelectedVersion ( 'latest' ) }
153- className = { cn (
154- 'w-full rounded-md px-3 py-2.5 text-left text-sm transition-all' ,
155- 'hover:bg-accent hover:text-accent-foreground' ,
156- 'border-2' ,
157- selectedVersion === 'latest'
158- ? 'bg-accent text-accent-foreground border-primary'
159- : 'border-transparent' ,
160- dir === 'rtl' && 'text-right' ,
161- ) }
162- >
163- < div className = "flex items-center justify-between" >
164- < div className = "flex items-center gap-2" >
165- < span className = "font-semibold" > { t ( 'nodeModal.latest' , { defaultValue : 'Latest' } ) } </ span >
166- < Badge variant = "secondary" className = "text-[10px] font-medium" >
167- { latestVersion }
168- </ Badge >
169- </ div >
170- { selectedVersion === 'latest' && (
171- < div className = "h-2 w-2 rounded-full bg-primary" />
172- ) }
173- </ div >
174- </ button >
175- ) }
176- { versions
177- . filter ( release => release . version !== latestVersion )
178- . slice ( 0 , 10 )
179- . map ( release => (
180- < button
181- key = { release . version }
182- type = "button"
183- onClick = { ( ) => setSelectedVersion ( release . version ) }
184- className = { cn (
185- 'w-full rounded-md px-3 py-2 text-left text-sm transition-all' ,
186- 'hover:bg-accent hover:text-accent-foreground' ,
187- 'border-2' ,
188- selectedVersion === release . version
189- ? 'bg-accent text-accent-foreground border-primary'
190- : 'border-transparent' ,
191- dir === 'rtl' && 'text-right' ,
192- ) }
193- >
194- < div className = "flex items-center justify-between" >
195- < span className = "font-mono" > { release . version } </ span >
196- { selectedVersion === release . version && (
197- < div className = "h-2 w-2 rounded-full bg-primary" />
176+ < div className = "space-y-3" >
177+ < Tabs value = { versionMode } onValueChange = { ( value ) => setVersionMode ( value as 'list' | 'custom' ) } className = "w-full" >
178+ < TabsList className = { cn ( 'grid w-full grid-cols-2' , dir === 'rtl' && 'flex-row-reverse' ) } >
179+ < TabsTrigger value = "list" className = { cn ( 'text-sm' , dir === 'rtl' && 'text-right' ) } >
180+ { t ( 'nodeModal.selectFromList' , { defaultValue : 'Select from List' } ) }
181+ </ TabsTrigger >
182+ < TabsTrigger value = "custom" className = { cn ( 'text-sm' , dir === 'rtl' && 'text-right' ) } >
183+ { t ( 'nodeModal.customVersion' , { defaultValue : 'Custom Version' } ) }
184+ </ TabsTrigger >
185+ </ TabsList >
186+
187+ < TabsContent value = "list" className = "mt-3" >
188+ { isLoadingReleases ? (
189+ < div className = { cn ( 'rounded-md border p-8 text-center' , dir === 'rtl' && 'text-right' ) } >
190+ < div className = "text-sm text-muted-foreground" >
191+ { t ( 'nodeModal.loadingReleases' , { defaultValue : 'Loading releases...' } ) }
192+ </ div >
193+ </ div >
194+ ) : (
195+ < ScrollArea className = "h-[200px] rounded-md border sm:h-[280px]" >
196+ < div className = "p-2 space-y-1" >
197+ { latestVersion && (
198+ < button
199+ type = "button"
200+ onClick = { ( ) => setSelectedVersion ( 'latest' ) }
201+ className = { cn (
202+ 'w-full rounded-md px-3 py-2.5 text-left text-sm transition-all' ,
203+ 'hover:bg-accent hover:text-accent-foreground' ,
204+ 'border-2' ,
205+ selectedVersion === 'latest'
206+ ? 'bg-accent text-accent-foreground border-primary'
207+ : 'border-transparent' ,
208+ dir === 'rtl' && 'text-right' ,
198209 ) }
199- </ div >
200- </ button >
201- ) ) }
210+ >
211+ < div className = "flex items-center justify-between" >
212+ < div className = { cn ( 'flex items-center gap-2' , dir === 'rtl' && 'flex-row-reverse' ) } >
213+ < span className = "font-semibold" > { t ( 'nodeModal.latest' , { defaultValue : 'Latest' } ) } </ span >
214+ < Badge variant = "secondary" className = "text-[10px] font-medium" >
215+ { latestVersion }
216+ </ Badge >
217+ </ div >
218+ { selectedVersion === 'latest' && (
219+ < div className = "h-2 w-2 rounded-full bg-primary" />
220+ ) }
221+ </ div >
222+ </ button >
223+ ) }
224+ { versions
225+ . filter ( release => release . version !== latestVersion )
226+ . slice ( 0 , 10 )
227+ . map ( release => (
228+ < button
229+ key = { release . version }
230+ type = "button"
231+ onClick = { ( ) => setSelectedVersion ( release . version ) }
232+ className = { cn (
233+ 'w-full rounded-md px-3 py-2 text-left text-sm transition-all' ,
234+ 'hover:bg-accent hover:text-accent-foreground' ,
235+ 'border-2' ,
236+ selectedVersion === release . version
237+ ? 'bg-accent text-accent-foreground border-primary'
238+ : 'border-transparent' ,
239+ dir === 'rtl' && 'text-right' ,
240+ ) }
241+ >
242+ < div className = "flex items-center justify-between" >
243+ < span className = "font-mono" > { release . version } </ span >
244+ { selectedVersion === release . version && (
245+ < div className = "h-2 w-2 rounded-full bg-primary" />
246+ ) }
247+ </ div >
248+ </ button >
249+ ) ) }
250+ </ div >
251+ </ ScrollArea >
252+ ) }
253+ </ TabsContent >
254+
255+ < TabsContent value = "custom" className = "mt-3 space-y-3" >
256+ < div className = "space-y-2" >
257+ < label htmlFor = "custom-version-input" className = { cn ( 'text-sm font-medium' , dir === 'rtl' && 'text-right' ) } >
258+ { t ( 'nodeModal.enterVersion' , { defaultValue : 'Enter Version' } ) }
259+ </ label >
260+ < Input
261+ id = "custom-version-input"
262+ type = "text"
263+ placeholder = { t ( 'nodeModal.versionPlaceholder' , { defaultValue : 'e.g., v1.8.0 or 1.8.0' } ) }
264+ value = { customVersion }
265+ onChange = { ( e ) => handleCustomVersionChange ( e . target . value ) }
266+ onBlur = { ( ) => {
267+ if ( customVersion ) {
268+ validateCustomVersion ( customVersion )
269+ }
270+ } }
271+ error = { customVersionError }
272+ isError = { ! ! customVersionError }
273+ className = "font-mono"
274+ />
275+ < p dir = { dir } className = { cn ( 'text-xs text-muted-foreground' , dir === 'rtl' && 'text-right' ) } >
276+ { t ( 'nodeModal.versionHint' , { defaultValue : 'Enter a version in the format vX.X.X or X.X.X (e.g., v1.8.0)' } ) }
277+ </ p >
202278 </ div >
203- </ ScrollArea >
204- ) }
279+ </ TabsContent >
280+ </ Tabs >
205281 </ div >
206282 </ div >
207283
@@ -212,7 +288,12 @@ export default function UpdateCoreDialog({ node, isOpen, onOpenChange }: UpdateC
212288 < LoaderButton
213289 className = "!m-0"
214290 onClick = { handleUpdate }
215- disabled = { updateCoreMutation . isPending || isLoadingReleases || ! latestVersion }
291+ disabled = {
292+ updateCoreMutation . isPending ||
293+ isLoadingReleases ||
294+ ( versionMode === 'list' && ! latestVersion ) ||
295+ ( versionMode === 'custom' && ( ! customVersion . trim ( ) || ! ! customVersionError ) )
296+ }
216297 isLoading = { updateCoreMutation . isPending }
217298 loadingText = { t ( 'nodeModal.updating' , { defaultValue : 'Updating...' } ) }
218299 >
0 commit comments