Skip to content

Commit b10cde0

Browse files
committed
feat(update-core-modal): implement custom version input and validation in update core dialog
1 parent 624087a commit b10cde0

File tree

6 files changed

+196
-87
lines changed

6 files changed

+196
-87
lines changed

dashboard/public/statics/locales/en.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1368,7 +1368,7 @@
13681368
"syncing": "Syncing...",
13691369
"syncSuccess": "Node synchronized successfully",
13701370
"syncFailed": "Failed to sync node",
1371-
"resetUsage": "Reset",
1371+
"resetUsage": "Reset Usage",
13721372
"resettingUsage": "Resetting usage...",
13731373
"resetUsageTitle": "Reset Node Usage",
13741374
"resetUsagePrompt": "Are you sure you want to reset usage for node «{{name}}»?",
@@ -1431,6 +1431,13 @@
14311431
"updateCoreDescription": "Update Xray core for node «{{nodeName}}»",
14321432
"updateCoreSuccess": "Xray core updated successfully",
14331433
"updateCoreFailed": "Failed to update Xray core: {{message}}",
1434+
"selectFromList": "Select from List",
1435+
"customVersion": "Custom Version",
1436+
"enterVersion": "Enter Version",
1437+
"versionPlaceholder": "e.g., v1.8.0 or 1.8.0",
1438+
"versionHint": "Enter a version in the format vX.X.X or X.X.X (e.g., v1.8.0)",
1439+
"customVersionRequired": "Version is required",
1440+
"invalidVersionFormat": "Invalid version format. Expected: vX.X.X or X.X.X",
14341441
"updateGeofiles": "Update Geofiles",
14351442
"updateGeofilesTitle": "Update Geofiles",
14361443
"updateGeofilesDescription": "Update Geofiles for node «{{nodeName}}»",

dashboard/public/statics/locales/fa.json

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,12 +1209,12 @@
12091209
"syncing": "در حال همگام‌سازی...",
12101210
"syncSuccess": "گره با موفقیت همگام‌سازی شد",
12111211
"syncFailed": "همگام‌سازی گره ناموفق بود",
1212-
"resetUsage": "بازنشانی",
1213-
"resettingUsage": "در حال بازنشانی استفاده...",
1214-
"resetUsageTitle": "بازنشانی استفاده گره",
1215-
"resetUsagePrompt": "آیا مطمئن هستید که می‌خواهید استفاده گره «{{name}}» را بازنشانی کنید؟",
1216-
"resetUsageSuccess": "استفاده گره با موفقیت بازنشانی شد",
1217-
"resetUsageFailed": "بازنشانی استفاده گره ناموفق بود",
1212+
"resetUsage": "بازنشانی مصرف",
1213+
"resettingUsage": "در حال بازنشانی مصرف...",
1214+
"resetUsageTitle": "بازنشانی مصرف گره",
1215+
"resetUsagePrompt": "آیا مطمئن هستید که می‌خواهید مصرف گره «{{name}}» را بازنشانی کنید؟",
1216+
"resetUsageSuccess": "مصرف گره با موفقیت بازنشانی شد",
1217+
"resetUsageFailed": "بازنشانی مصرف گره ناموفق بود",
12181218
"dataLimit": "محدودیت داده",
12191219
"dataLimitPlaceholder": "محدودیت داده را وارد کنید (گیگابایت)",
12201220
"dataLimitResetStrategy": "استراتژی بازنشانی محدودیت داده",
@@ -1272,6 +1272,13 @@
12721272
"updateCoreDescription": "به‌روزرسانی هسته Xray برای گره «{{nodeName}}»",
12731273
"updateCoreSuccess": "به‌روزرسانی هسته Xray با موفقیت انجام شد",
12741274
"updateCoreFailed": "به‌روزرسانی هسته Xray ناموفق بود: {{message}}",
1275+
"selectFromList": "انتخاب از فهرست",
1276+
"customVersion": "نسخه سفارشی",
1277+
"enterVersion": "وارد کردن نسخه",
1278+
"versionPlaceholder": "مثال: v1.8.0 یا 1.8.0",
1279+
"versionHint": "نسخه را به فرمت vX.X.X یا X.X.X وارد کنید (مثال: v1.8.0)",
1280+
"customVersionRequired": "نسخه الزامی است",
1281+
"invalidVersionFormat": "فرمت نسخه نامعتبر است. فرمت مورد انتظار: vX.X.X یا X.X.X",
12751282
"updateGeofiles": "به‌روزرسانی فایل های Geo",
12761283
"updateGeofilesTitle": "به‌روزرسانی فایل‌های Geo",
12771284
"updateGeofilesDescription": "به‌روزرسانی فایل‌های Geo برای گره «{{nodeName}}»",

dashboard/public/statics/locales/ru.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1266,7 +1266,7 @@
12661266
"syncing": "Синхронизация...",
12671267
"syncSuccess": "Узел успешно синхронизирован",
12681268
"syncFailed": "Не удалось синхронизировать узел",
1269-
"resetUsage": "Сбросить",
1269+
"resetUsage": "Сбросить использование",
12701270
"resettingUsage": "Сброс использования...",
12711271
"resetUsageTitle": "Сбросить использование узла",
12721272
"resetUsagePrompt": "Вы уверены, что хотите сбросить использование для узла «{{name}}»?",
@@ -1329,6 +1329,13 @@
13291329
"updateCoreDescription": "Обновить Xray core для узла «{{nodeName}}»",
13301330
"updateCoreSuccess": "Xray core успешно обновлен",
13311331
"updateCoreFailed": "Не удалось обновить Xray core: {{message}}",
1332+
"selectFromList": "Выбрать из списка",
1333+
"customVersion": "Пользовательская версия",
1334+
"enterVersion": "Ввести версию",
1335+
"versionPlaceholder": "например, v1.8.0 или 1.8.0",
1336+
"versionHint": "Введите версию в формате vX.X.X или X.X.X (например, v1.8.0)",
1337+
"customVersionRequired": "Версия обязательна",
1338+
"invalidVersionFormat": "Неверный формат версии. Ожидается: vX.X.X или X.X.X",
13321339
"updateGeofiles": "Обновить гео",
13331340
"updateGeofilesTitle": "Обновить геофайлы",
13341341
"updateGeofilesDescription": "Обновить геофайлы для узла «{{nodeName}}»",

dashboard/public/statics/locales/zh.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1313,7 +1313,7 @@
13131313
"syncing": "同步中...",
13141314
"syncSuccess": "节点同步成功",
13151315
"syncFailed": "节点同步失败",
1316-
"resetUsage": "重置",
1316+
"resetUsage": "重置使用量",
13171317
"resettingUsage": "正在重置使用量...",
13181318
"resetUsageTitle": "重置节点使用量",
13191319
"resetUsagePrompt": "你确定要重置节点 «{{name}}» 的使用量吗?",
@@ -1391,6 +1391,13 @@
13911391
"updateCoreDescription": "更新节点 «{{nodeName}}» 的 Xray 核心",
13921392
"updateCoreSuccess": "Xray 核心更新成功",
13931393
"updateCoreFailed": "更新 Xray 核心失败: {{message}}",
1394+
"selectFromList": "从列表选择",
1395+
"customVersion": "自定义版本",
1396+
"enterVersion": "输入版本",
1397+
"versionPlaceholder": "例如:v1.8.0 或 1.8.0",
1398+
"versionHint": "输入格式为 vX.X.X 或 X.X.X 的版本(例如:v1.8.0)",
1399+
"customVersionRequired": "版本是必需的",
1400+
"invalidVersionFormat": "版本格式无效。预期格式:vX.X.X 或 X.X.X",
13941401
"updateGeofiles": "更新 Geo 文件",
13951402
"updateGeofilesTitle": "更新 Geo 文件",
13961403
"updateGeofilesDescription": "更新节点 «{{nodeName}}» 的 Geo 文件",

dashboard/src/components/dialogs/update-core-modal.tsx

Lines changed: 158 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { ExternalLink } from 'lucide-react'
1212
import { Badge } from '@/components/ui/badge'
1313
import { cn } from '@/lib/utils'
1414
import useDirDetection from '@/hooks/use-dir-detection'
15+
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
16+
import { Input } from '@/components/ui/input'
1517

1618
interface 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

Comments
 (0)