Skip to content

Commit 68fe928

Browse files
committed
feat: add clone functionality to BaseHostForm and update localization
- Introduced a clone button in BaseHostForm to allow users to duplicate host configurations. - Updated localization files for English, Persian, and Russian to include new translation for "Clone". - Enhanced EditHostModalWidget to handle cloning of hosts with appropriate parameters. - Modified IProps interface to include optional handleCloneHost function.
1 parent 737de78 commit 68fe928

File tree

7 files changed

+111
-55
lines changed

7 files changed

+111
-55
lines changed

public/locales/en/remnawave.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@
310310
"extra-xhttp-description": "This extra params will work only with xHTTP protocol. Pass object \"extra\" to the client. This JSON input is not validated, be sure to paste the correct extra params. Use the button below to copy the basic extra params, then paste it into the JSON input.",
311311
"invalid-json": "Invalid JSON",
312312
"fill-with-sample-xhttp-extra-params": "Fill with sample xHTTP extra params",
313-
"close": "Close"
313+
"close": "Close",
314+
"clone": "Clone"
314315
},
315316
"base-node-form": {
316317
"country": "Country",
@@ -808,4 +809,4 @@
808809
"updated-at": "Updated At"
809810
}
810811
}
811-
}
812+
}

public/locales/fa/remnawave.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@
310310
"extra-xhttp-description": "This extra params will work only with xHTTP protocol. Pass object \"extra\" to the client. This JSON input is not validated, be sure to paste the correct extra params. Use the button below to copy the basic extra params, then paste it into the JSON input.",
311311
"invalid-json": "Invalid JSON",
312312
"fill-with-sample-xhttp-extra-params": "Fill with sample xHTTP extra params",
313-
"close": "Close"
313+
"close": "Close",
314+
"clone": "Clone"
314315
},
315316
"base-node-form": {
316317
"country": "کشور",
@@ -808,4 +809,4 @@
808809
"updated-at": "Updated At"
809810
}
810811
}
811-
}
812+
}

public/locales/ru/remnawave.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,8 @@
310310
"extra-xhttp-description": "This extra params will work only with xHTTP protocol. Pass object \"extra\" to the client. This JSON input is not validated, be sure to paste the correct extra params. Use the button below to copy the basic extra params, then paste it into the JSON input.",
311311
"invalid-json": "Invalid JSON",
312312
"fill-with-sample-xhttp-extra-params": "Fill with sample xHTTP extra params",
313-
"close": "Close"
313+
"close": "Close",
314+
"clone": "Clone"
314315
},
315316
"base-node-form": {
316317
"country": "Страна",
@@ -808,4 +809,4 @@
808809
"updated-at": "Updated At"
809810
}
810811
}
811-
}
812+
}

src/shared/ui/forms/hosts/base-host-form/base-host-form.tsx

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
PiArrowUpDuotone,
1818
PiCaretDown,
1919
PiCaretUp,
20+
PiCopyDuotone,
2021
PiFloppyDiskDuotone,
2122
PiInfo,
2223
PiPencilDuotone
@@ -85,8 +86,16 @@ const pasteBasicXHttpExtraParams = `{
8586
export const BaseHostForm = <T extends CreateHostCommand.Request | UpdateHostCommand.Request>(
8687
props: IProps<T>
8788
) => {
88-
const { form, advancedOpened, handleSubmit, host, inbounds, setAdvancedOpened, isSubmitting } =
89-
props
89+
const {
90+
form,
91+
advancedOpened,
92+
handleSubmit,
93+
host,
94+
inbounds,
95+
setAdvancedOpened,
96+
isSubmitting,
97+
handleCloneHost
98+
} = props
9099

91100
const { t } = useTranslation()
92101
const [opened, { open, close }] = useDisclosure(false)
@@ -317,17 +326,32 @@ export const BaseHostForm = <T extends CreateHostCommand.Request | UpdateHostCom
317326
<DeleteHostFeature />
318327
</ActionIcon.Group>
319328

320-
<Button
321-
color="blue"
322-
disabled={!form.isValid() || !form.isDirty() || !form.isTouched()}
323-
leftSection={<PiFloppyDiskDuotone size="1rem" />}
324-
loading={isSubmitting}
325-
size="md"
326-
type="submit"
327-
variant="outline"
328-
>
329-
{t('base-host-form.save')}
330-
</Button>
329+
<Group gap="xs">
330+
{handleCloneHost && (
331+
<Button
332+
color="blue"
333+
leftSection={<PiCopyDuotone size="1rem" />}
334+
loading={isSubmitting}
335+
onClick={handleCloneHost}
336+
size="md"
337+
variant="light"
338+
>
339+
{t('base-host-form.clone')}
340+
</Button>
341+
)}
342+
343+
<Button
344+
color="blue"
345+
disabled={!form.isValid() || !form.isDirty() || !form.isTouched()}
346+
leftSection={<PiFloppyDiskDuotone size="1rem" />}
347+
loading={isSubmitting}
348+
size="md"
349+
type="submit"
350+
variant="outline"
351+
>
352+
{t('base-host-form.save')}
353+
</Button>
354+
</Group>
331355
</Group>
332356

333357
<Drawer

src/shared/ui/forms/hosts/base-host-form/interfaces/iprops.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { UseFormReturnType } from '@mantine/form'
88
export interface IProps<T extends CreateHostCommand.Request | UpdateHostCommand.Request> {
99
advancedOpened: boolean
1010
form: UseFormReturnType<T>
11+
handleCloneHost?: () => void
1112
handleSubmit: () => void
1213
host?: UpdateHostCommand.Response['response']
1314
inbounds: GetInboundsCommand.Response['response']

src/widgets/dashboard/hosts/edit-host-modal/edit-host-modal.widget.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import {
99
useHostsStoreEditModalHost,
1010
useHostsStoreEditModalIsOpen
1111
} from '@entities/dashboard'
12+
import { useCreateHost, useGetInbounds, useUpdateHost } from '@shared/api/hooks'
1213
import { BaseHostForm } from '@shared/ui/forms/hosts/base-host-form'
13-
import { useGetInbounds, useUpdateHost } from '@shared/api/hooks'
1414

1515
export const EditHostModalWidget = () => {
1616
const { t } = useTranslation()
@@ -50,6 +50,14 @@ export const EditHostModalWidget = () => {
5050
}
5151
})
5252

53+
const { mutate: createHost } = useCreateHost({
54+
mutationFns: {
55+
onSuccess: async () => {
56+
handleClose()
57+
}
58+
}
59+
})
60+
5361
useEffect(() => {
5462
if (host && inbounds) {
5563
let xHttpExtraParamsParsed: null | object | string
@@ -113,6 +121,29 @@ export const EditHostModalWidget = () => {
113121
})
114122
})
115123

124+
const handleCloneHost = () => {
125+
if (!host) {
126+
return
127+
}
128+
129+
createHost({
130+
variables: {
131+
...host,
132+
remark: `Clone #${Math.random().toString(36).substring(2, 15)}`,
133+
port: host.port,
134+
inboundUuid: host.inboundUuid,
135+
isDisabled: true,
136+
path: host.path ?? undefined,
137+
sni: host.sni ?? undefined,
138+
host: host.host ?? undefined,
139+
alpn: (host.alpn as UpdateHostCommand.Request['alpn']) ?? undefined,
140+
xHttpExtraParams: host.xHttpExtraParams ?? undefined,
141+
fingerprint:
142+
(host.fingerprint as UpdateHostCommand.Request['fingerprint']) ?? undefined
143+
}
144+
})
145+
}
146+
116147
return (
117148
<Modal
118149
centered
@@ -123,6 +154,7 @@ export const EditHostModalWidget = () => {
123154
<BaseHostForm
124155
advancedOpened={advancedOpened}
125156
form={form}
157+
handleCloneHost={handleCloneHost}
126158
handleSubmit={handleSubmit}
127159
host={host!}
128160
inbounds={inbounds ?? []}

src/widgets/dashboard/hosts/hosts-table/hosts-table.widget.tsx

Lines changed: 31 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,61 @@
11
import { DragDropContext, Droppable, DropResult } from '@hello-pangea/dnd'
2-
import { memo, useCallback, useEffect, useMemo } from 'react'
32
import { useListState } from '@mantine/hooks'
3+
import { useEffect } from 'react'
44

55
import { HostCardWidget } from '@widgets/dashboard/hosts/host-card'
66
import { EmptyPageLayout } from '@shared/ui/layouts/empty-page'
77
import { useReorderHosts } from '@shared/api/hooks'
88

99
import { IProps } from './interfaces'
1010

11-
const MemoizedHostCard = memo(HostCardWidget)
12-
1311
export function HostsTableWidget(props: IProps) {
1412
const { inbounds, hosts, selectedHosts, setSelectedHosts } = props
13+
1514
const [state, handlers] = useListState(hosts || [])
1615

1716
const { mutate: reorderHosts } = useReorderHosts()
1817

19-
const checkOrderAndReorder = useCallback(() => {
20-
if (!hosts || !state) return
21-
22-
const hasOrderChanged = hosts.some((host, index) => state[index]?.uuid !== host.uuid)
18+
useEffect(() => {
19+
;(async () => {
20+
if (!hosts || !state) {
21+
return
22+
}
2323

24-
if (hasOrderChanged) {
2524
const updatedHosts = hosts.map((host) => ({
2625
uuid: host.uuid,
2726
viewPosition: state.findIndex((stateItem) => stateItem.uuid === host.uuid)
2827
}))
2928

30-
reorderHosts({ variables: { hosts: updatedHosts } })
31-
}
32-
}, [hosts, state, reorderHosts])
29+
const hasOrderChanged = hosts?.some((host, index) => host.uuid !== state[index].uuid)
3330

34-
useEffect(() => {
35-
checkOrderAndReorder()
36-
}, [checkOrderAndReorder])
31+
if (hasOrderChanged) {
32+
reorderHosts({ variables: { hosts: updatedHosts } })
33+
}
34+
})()
35+
}, [state])
3736

3837
useEffect(() => {
3938
handlers.setState(hosts || [])
4039
}, [hosts])
4140

42-
const toggleHostSelection = useCallback(
43-
(hostId: string) => {
44-
setSelectedHosts((prev) =>
45-
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
46-
)
47-
},
48-
[setSelectedHosts]
49-
)
41+
if (!hosts || !inbounds) {
42+
return null
43+
}
5044

51-
const handleDragEnd = useCallback(
52-
async (result: DropResult) => {
53-
const { destination, source } = result
54-
handlers.reorder({ from: source.index, to: destination?.index || 0 })
55-
},
56-
[handlers]
57-
)
45+
if (hosts.length === 0) {
46+
return <EmptyPageLayout />
47+
}
48+
49+
const handleDragEnd = async (result: DropResult) => {
50+
const { destination, source } = result
51+
handlers.reorder({ from: source.index, to: destination?.index || 0 })
52+
}
5853

59-
const selectedHostsMap = useMemo(() => {
60-
const map = new Set(selectedHosts)
61-
return map
62-
}, [selectedHosts])
54+
const toggleHostSelection = (hostId: string) => {
55+
setSelectedHosts((prev) =>
56+
prev.includes(hostId) ? prev.filter((id) => id !== hostId) : [...prev, hostId]
57+
)
58+
}
6359

6460
if (!hosts || !inbounds) return null
6561
if (hosts.length === 0) return <EmptyPageLayout />
@@ -71,10 +67,10 @@ export function HostsTableWidget(props: IProps) {
7167
<div {...provided.droppableProps} ref={provided.innerRef}>
7268
{state.map((item, index) => (
7369
<div key={item.uuid} style={{ position: 'relative' }}>
74-
<MemoizedHostCard
70+
<HostCardWidget
7571
inbounds={inbounds}
7672
index={index}
77-
isSelected={selectedHostsMap.has(item.uuid)}
73+
isSelected={selectedHosts.includes(item.uuid)}
7874
item={item}
7975
onSelect={() => toggleHostSelection(item.uuid)}
8076
/>

0 commit comments

Comments
 (0)