diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ee992b..88dcf45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,9 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tabler/core": "^1.3.2", "@tabler/icons-react": "^3.34.0", "@tanstack/react-query": "^5.56.2", @@ -290,6 +293,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/win32-x64": { "version": "0.25.5", "cpu": [ @@ -2213,6 +2269,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 2b22468..a757865 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,15 +12,18 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tabler/core": "^1.3.2", "@tabler/icons-react": "^3.34.0", "@tanstack/react-query": "^5.56.2", "@tanstack/react-virtual": "^3.10.8", + "@xyflow/react": "^12.3.4", "axios": "^1.10.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router-dom": "^6.30.1", - "@xyflow/react": "^12.3.4" + "react-router-dom": "^6.30.1" }, "devDependencies": { "@eslint/js": "^9.29.0", diff --git a/frontend/src/FilterManager.jsx b/frontend/src/FilterManager.jsx index ce31e4c..6ea57d0 100644 --- a/frontend/src/FilterManager.jsx +++ b/frontend/src/FilterManager.jsx @@ -31,8 +31,25 @@ import { IconSortAscending, IconSortDescending, IconWorld, - IconFileText + IconFileText, + IconGripVertical } from '@tabler/icons-react'; +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import { normalizeGateways, countryToFlag } from './utils/serverUtils.js'; import { AddFilterModal, @@ -514,6 +531,138 @@ function GatewayAutocomplete({ label, value, onChange, gateways = [], required = ); } +// Компонент перетаскиваемой строки фильтра +function SortableFilterRow({ + filter, + index, + filterKey, + isSelected, + onToggleSelect, + onEdit, + onDelete, + communities, + baseAS, + selectedServerGateways +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: filterKey }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + backgroundColor: isDragging ? 'var(--tblr-bg-surface-secondary)' : undefined, + }; + + return ( + + +
+ +
+ + + + + + #{index + 1} + + +
+ + + +
+ {filter?.community || ''} + {(() => { + const communityData = communities.find(c => { + if (c.value === filter?.community) return true; + const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`; + return normalized === filter?.community; + }); + if (communityData && (communityData.name || communityData.description)) { + return ( +
+ {communityData.name &&
{communityData.name}
} + {communityData.description &&
{communityData.description}
} +
+ ); + } + return null; + })()} +
+
+ + +
+ + + +
+ {filter?.gateway || ''} + {(() => { + const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway); + if (gatewayData && (gatewayData.ip || gatewayData.comment)) { + return ( +
+ {gatewayData.ip &&
{gatewayData.ip}
} + {gatewayData.comment &&
{gatewayData.comment}
} +
+ ); + } + return null; + })()} +
+
+ + +
+ {filter?.description ? ( + {filter.description} + ) : ( + Без описания + )} +
+ + +
+ + +
+ + + ); +} + function FilterManager() { const [error, setError] = useState(''); const [success, setSuccess] = useState(''); @@ -1338,6 +1487,47 @@ function FilterManager() { } }; + // Drag and drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Обработчик завершения перетаскивания + const handleDragEnd = async (event) => { + const { active, over } = event; + + if (!over || active.id === over.id) return; + + // Находим индексы в исходном массиве serverFilters + const oldIndex = serverFilters.findIndex(f => filterKey(f) === active.id); + const newIndex = serverFilters.findIndex(f => filterKey(f) === over.id); + + if (oldIndex === -1 || newIndex === -1) return; + + // Перемещаем элемент + const newFilters = arrayMove(serverFilters, oldIndex, newIndex); + setServerFilters(newFilters); + + // Сохраняем новый порядок + try { + await api.post(`/server-filters/${selectedServer.id}`, { filters: newFilters }); + setSuccess('Порядок фильтров обновлён'); + setTimeout(() => setSuccess(''), 2000); + } catch (error) { + console.error('Error saving filter order:', error); + setError('Не удалось сохранить порядок фильтров'); + // Откатываем изменения + setServerFilters(serverFilters); + } + }; + // Убрано сохранение режима в localStorage по просьбе пользователя const toggleSelectFilter = (key) => { @@ -1807,10 +1997,20 @@ function FilterManager() { ) : ( <> +
+ + Перетащите строки за иконку чтобы изменить приоритет правил +
+
+ - + - - - + + f != null).map(f => filterKey(f))} + strategy={verticalListSortingStrategy} + > - {processedFilters.filter(f => f != null).map((filter, index) => { + {serverFilters.filter(f => f != null).map((filter, index) => { const key = filterKey(filter); const isSelected = selectedFilterKeys.has(key); + // Показываем только если совпадает с поиском + if (filterSearch.trim()) { + const search = filterSearch.trim().toLowerCase(); + const c = String(filter.community || '').toLowerCase(); + const g = String(filter.gateway || '').toLowerCase(); + const d = String(filter.description || '').toLowerCase(); + if (!c.includes(search) && !g.includes(search) && !d.includes(search)) { + return null; + } + } return ( - - - - - - - - );})} + toggleSelectFilter(key)} + onEdit={() => handleEditFilter(filter, index)} + onDelete={() => { + setFilterToDelete(filter); + setDeleteFilterModalOpen(true); + }} + communities={communities} + baseAS={baseAS} + selectedServerGateways={selectedServerGateways} + /> + ); + })} +
toggleSort('community')}> + # toggleSort('community')}> COMMUNITY {sortBy === 'community' && (sortDir === 'asc' ? : )} toggleSort('gateway')}> + toggleSort('gateway')}> GATEWAY {sortBy === 'gateway' && (sortDir === 'asc' ? : )} toggleSort('description')}> + toggleSort('description')}> ОПИСАНИЕ {sortBy === 'description' && (sortDir === 'asc' ? : )} ДЕЙСТВИЯДЕЙСТВИЯ
- toggleSelectFilter(key)} - /> - -
- - - -
- {filter?.community || ''} - {(() => { - // Ищем community в справочнике - const communityData = communities.find(c => { - if (c.value === filter?.community) return true; - const normalized = c.value.includes(':') ? c.value : `${baseAS}:${c.value}`; - return normalized === filter?.community; - }); - if (communityData && (communityData.name || communityData.description)) { - return ( -
- {communityData.name &&
{communityData.name}
} - {communityData.description &&
{communityData.description}
} -
- ); - } - return null; - })()} -
-
-
-
- - - -
- {filter?.gateway || ''} - {(() => { - // Ищем gateway в списке шлюзов сервера - const gatewayData = selectedServerGateways.find(gw => gw.name === filter?.gateway); - if (gatewayData && (gatewayData.ip || gatewayData.comment)) { - return ( -
- {gatewayData.ip &&
{gatewayData.ip}
} - {gatewayData.comment &&
{gatewayData.comment}
} -
- ); - } - return null; - })()} -
-
-
-
- {filter?.description ? ( - {filter.description} - ) : ( - Без описания - )} -
-
-
- - -
-
+
{selectedFilterKeys.size > 0 && (
Выбрано: {selectedFilterKeys.size}