Publish Docker image / build-and-push (push) Successful in 2m6s
- Changed the main entry point from main.jsx to main.tsx for TypeScript support. - Removed Tabler JS import and integrated Tailwind CSS for styling. - Updated package.json and package-lock.json to include new dependencies such as @fontsource-variable/geist and tailwindcss, while removing unused ones. - Enhanced Vite configuration with path aliasing for improved import management. - Deleted unused App.css and App.jsx files to streamline the project structure. Made-with: Cursor
938 lines
37 KiB
React
938 lines
37 KiB
React
import { useEffect, useMemo, useState } from 'react';
|
||
import api from './lib/api.js';
|
||
import {
|
||
IconPlus,
|
||
IconSearch,
|
||
IconEdit,
|
||
IconTrash,
|
||
IconCheck,
|
||
IconX,
|
||
IconHash,
|
||
IconFilter,
|
||
IconChartBar,
|
||
IconList,
|
||
IconRefresh,
|
||
IconSortAscending,
|
||
IconSortDescending,
|
||
IconUpload,
|
||
IconDownload,
|
||
IconDeviceFloppy,
|
||
IconPlugConnected
|
||
} from '@/lib/icons';
|
||
import PageHeader from './components/PageHeader.jsx';
|
||
import PortalDropdown from './components/PortalDropdown.jsx';
|
||
import EmptyState from './components/EmptyState.jsx';
|
||
import CommunityStats from './components/CommunityStats.jsx';
|
||
import SavedFilters from './components/SavedFilters.jsx';
|
||
import FormModal from './components/FormModal.jsx';
|
||
import ConfirmModal from './components/ConfirmModal.jsx';
|
||
import FormField from './components/FormField.jsx';
|
||
import { ErrorAlert } from '@/components/shared/feedback';
|
||
import Pagination from './components/Pagination.jsx';
|
||
import IconPicker from './components/IconPicker.jsx';
|
||
import { getIconById } from './lib/brandIcons.js';
|
||
import { listCommunities, saveCommunitiesData, normalizeEvobgpApiBaseUrl } from './lib/evobgpData.js';
|
||
|
||
const normalizeCommunityValue = (value) => {
|
||
const trimmed = String(value || '').trim();
|
||
if (!trimmed) return '';
|
||
// Если ASN не указан, используем 65001 по умолчанию
|
||
return trimmed.includes(':') ? trimmed : `65001:${trimmed}`;
|
||
};
|
||
|
||
function CommunitiesManager() {
|
||
const [items, setItems] = useState([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState('');
|
||
const [success, setSuccess] = useState('');
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [sortField, setSortField] = useState('value');
|
||
const [sortOrder, setSortOrder] = useState('asc');
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const pageSize = 10;
|
||
|
||
// Состояние для переключения между списком и статистикой
|
||
const [activeTab, setActiveTab] = useState('list'); // 'list' | 'stats'
|
||
|
||
const [newItem, setNewItem] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
|
||
const [editingValue, setEditingValue] = useState(null);
|
||
const [editingDraft, setEditingDraft] = useState({ value: '', name: '', description: '', tags: '', color: '', icon: '', category: '', priority: 0, enabled: true });
|
||
const [filterUsage, setFilterUsage] = useState({});
|
||
const [filterUsageLoading, setFilterUsageLoading] = useState(false);
|
||
const [filterUsageError, setFilterUsageError] = useState('');
|
||
const [showOnlyUsedInFilters, setShowOnlyUsedInFilters] = useState(false);
|
||
|
||
const [evobgpUrlDraft, setEvobgpUrlDraft] = useState('');
|
||
const [evobgpTokenDraft, setEvobgpTokenDraft] = useState('');
|
||
const [evobgpSettingsOpen, setEvobgpSettingsOpen] = useState(() => {
|
||
if (import.meta.env?.VITE_EVOBGP_API_URL) return false;
|
||
try {
|
||
return Boolean(window.localStorage?.getItem('evobgp_api_url'));
|
||
} catch {
|
||
return false;
|
||
}
|
||
});
|
||
|
||
// Модальные окна
|
||
const [addModalOpen, setAddModalOpen] = useState(false);
|
||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||
const [itemToDelete, setItemToDelete] = useState(null);
|
||
|
||
useEffect(() => {
|
||
fetchItems();
|
||
fetchFilterUsage();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
try {
|
||
setEvobgpUrlDraft(String(window.localStorage?.getItem('evobgp_api_url') || ''));
|
||
setEvobgpTokenDraft(String(window.localStorage?.getItem('evobgp_api_token') || ''));
|
||
} catch {
|
||
/* no-op */
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!filterUsage) return;
|
||
setItems((prev) => {
|
||
const map = new Map(prev.map((i) => [i.value, i]));
|
||
Object.keys(filterUsage).forEach((value) => {
|
||
if (!value) return;
|
||
if (!map.has(value)) {
|
||
map.set(value, { value, name: '', description: '', tags: [], color: '', icon: '', _external: true });
|
||
}
|
||
});
|
||
return Array.from(map.values());
|
||
});
|
||
}, [filterUsage]);
|
||
|
||
const fetchItems = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const rows = await listCommunities();
|
||
const normalized = rows.map((item) => ({
|
||
...item,
|
||
value: normalizeCommunityValue(item.value),
|
||
}));
|
||
setItems(normalized);
|
||
setError('');
|
||
} catch (e) {
|
||
console.error('Error fetching communities:', e);
|
||
setError('Не удалось загрузить справочник community.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const fetchFilterUsage = async () => {
|
||
setFilterUsageLoading(true);
|
||
setFilterUsageError('');
|
||
try {
|
||
const serversRes = await api.get(`/server-configs`);
|
||
const serversList = Array.isArray(serversRes.data) ? serversRes.data : [];
|
||
if (!serversList.length) {
|
||
setFilterUsage({});
|
||
return;
|
||
}
|
||
|
||
const responses = await Promise.all(
|
||
serversList.map((server) =>
|
||
api.get(`/server-filters/${server.id}`).catch(() => ({ data: [] }))
|
||
)
|
||
);
|
||
|
||
const usageMap = new Map();
|
||
responses.forEach((res) => {
|
||
const arr = Array.isArray(res.data) ? res.data : [];
|
||
arr.forEach((f) => {
|
||
const value = normalizeCommunityValue(f?.community);
|
||
if (!value) return;
|
||
usageMap.set(value, (usageMap.get(value) || 0) + 1);
|
||
});
|
||
});
|
||
|
||
setFilterUsage(Object.fromEntries(usageMap));
|
||
} catch (e) {
|
||
console.error('Error fetching filter usage:', e);
|
||
setFilterUsageError('Не удалось загрузить использование в фильтрах.');
|
||
} finally {
|
||
setFilterUsageLoading(false);
|
||
}
|
||
};
|
||
|
||
const saveAll = async (data) => {
|
||
setLoading(true);
|
||
try {
|
||
await saveCommunitiesData(data);
|
||
setSuccess('Справочник сохранён!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
} catch (e) {
|
||
console.error('Error saving communities:', e);
|
||
setError(e.response?.data || 'Не удалось сохранить справочник.');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const toTagsArray = (str) => (str || '').split(',').map(t => t.trim()).filter(Boolean);
|
||
const fromTagsArray = (arr) => (arr || []).join(', ');
|
||
|
||
const addItem = () => {
|
||
const value = normalizeCommunityValue(newItem.value);
|
||
if (!value) { setError('Поле value обязательно.'); return; }
|
||
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
|
||
if (items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||
setError('');
|
||
setItems([...items, {
|
||
value,
|
||
name: String(newItem.name || ''),
|
||
description: String(newItem.description || ''),
|
||
tags: toTagsArray(newItem.tags),
|
||
color: String(newItem.color || ''),
|
||
icon: String(newItem.icon || ''),
|
||
}]);
|
||
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
|
||
setAddModalOpen(false);
|
||
setSuccess('Community успешно добавлен!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
};
|
||
|
||
const startEdit = (item) => {
|
||
setEditingValue(item.value);
|
||
setEditingDraft({
|
||
value: item.value,
|
||
name: item.name || '',
|
||
description: item.description || '',
|
||
tags: fromTagsArray(item.tags),
|
||
color: item.color || '',
|
||
icon: item.icon || '',
|
||
});
|
||
setEditModalOpen(true);
|
||
};
|
||
|
||
const saveEdit = () => {
|
||
const value = normalizeCommunityValue(editingDraft.value);
|
||
if (!value) { setError('Поле value обязательно.'); return; }
|
||
if (!/^(\d+|\d+:\d+)$/.test(value)) { setError('Формат: число или N:N'); return; }
|
||
if (value !== editingValue && items.some(i => i.value === value)) { setError('Такое value уже существует.'); return; }
|
||
const next = items.map(i => i.value === editingValue ? {
|
||
value,
|
||
name: String(editingDraft.name || ''),
|
||
description: String(editingDraft.description || ''),
|
||
tags: toTagsArray(editingDraft.tags),
|
||
color: String(editingDraft.color || ''),
|
||
icon: String(editingDraft.icon || ''),
|
||
} : i);
|
||
setItems(next);
|
||
setEditingValue(null);
|
||
setEditModalOpen(false);
|
||
setSuccess('Community успешно обновлён!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
};
|
||
|
||
const cancelEdit = () => {
|
||
setEditingValue(null);
|
||
setEditModalOpen(false);
|
||
};
|
||
|
||
const handleDeleteClick = (item) => {
|
||
setItemToDelete(item);
|
||
setDeleteModalOpen(true);
|
||
};
|
||
|
||
const confirmDelete = () => {
|
||
if (itemToDelete) {
|
||
setItems(items.filter(i => i.value !== itemToDelete.value));
|
||
setDeleteModalOpen(false);
|
||
setItemToDelete(null);
|
||
setSuccess('Community успешно удалён!');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
}
|
||
};
|
||
|
||
const handleImport = () => {
|
||
const text = window.prompt('Вставьте JSON-массив community (value, name, description, tags, color, icon)');
|
||
if (!text) return;
|
||
try {
|
||
const arr = JSON.parse(text);
|
||
if (!Array.isArray(arr)) throw new Error('Ожидается массив');
|
||
setItems(prev => {
|
||
const map = new Map(prev.map(i => [i.value, i]));
|
||
arr.forEach(x => {
|
||
if (!x || !x.value) return;
|
||
const norm = normalizeCommunityValue(x.value);
|
||
if (!norm) return;
|
||
if (map.has(norm)) return;
|
||
map.set(norm, {
|
||
value: norm,
|
||
name: String(x.name || ''),
|
||
description: String(x.description || ''),
|
||
tags: Array.isArray(x.tags) ? x.tags : [],
|
||
color: String(x.color || ''),
|
||
icon: String(x.icon || ''),
|
||
});
|
||
});
|
||
return Array.from(map.values());
|
||
});
|
||
} catch (_e) {
|
||
setError('Неверный JSON.');
|
||
}
|
||
};
|
||
|
||
const handleExport = () => {
|
||
const blob = new Blob([JSON.stringify(items, null, 2)], { type: 'application/json;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `communities_${new Date().toISOString().split('T')[0]}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
const handleSave = () => saveAll(items);
|
||
|
||
const saveEvobgpConnection = () => {
|
||
const trimmedUrl = String(evobgpUrlDraft || '').trim();
|
||
if (!trimmedUrl) {
|
||
try {
|
||
window.localStorage.removeItem('evobgp_api_url');
|
||
window.localStorage.removeItem('evobgp_api_token');
|
||
} catch (_e) {
|
||
setError('Не удалось очистить настройки в localStorage.');
|
||
return;
|
||
}
|
||
setEvobgpUrlDraft('');
|
||
setEvobgpTokenDraft('');
|
||
setError('');
|
||
setSuccess('Включён серверный прокси /api/evobgp (EVOBGP_API_URL и EVOBGP_API_TOKEN в контейнере).');
|
||
fetchItems();
|
||
setTimeout(() => setSuccess(''), 5000);
|
||
return;
|
||
}
|
||
const norm = normalizeEvobgpApiBaseUrl(trimmedUrl);
|
||
if (!norm) {
|
||
setError('Укажите корректный URL EvoBGP (например http://192.168.0.1:8080). Порт — у API EvoBGP, не у этого интерфейса.');
|
||
return;
|
||
}
|
||
try {
|
||
window.localStorage.setItem('evobgp_api_url', norm);
|
||
window.localStorage.setItem('evobgp_api_token', String(evobgpTokenDraft || '').trim());
|
||
} catch (_e) {
|
||
setError('Не удалось сохранить настройки в localStorage.');
|
||
return;
|
||
}
|
||
setEvobgpUrlDraft(norm);
|
||
setError('');
|
||
setSuccess('Прямое подключение к EvoBGP из браузера сохранено. Загружаем данные…');
|
||
fetchItems();
|
||
setTimeout(() => setSuccess(''), 4000);
|
||
};
|
||
|
||
const sorted = useMemo(() => {
|
||
const arr = [...items];
|
||
arr.sort((a, b) => {
|
||
let va = (a[sortField] ?? '').toString().toLowerCase();
|
||
let vb = (b[sortField] ?? '').toString().toLowerCase();
|
||
if (va < vb) return sortOrder === 'asc' ? -1 : 1;
|
||
if (va > vb) return sortOrder === 'asc' ? 1 : -1;
|
||
return 0;
|
||
});
|
||
return arr;
|
||
}, [items, sortField, sortOrder]);
|
||
|
||
const filtered = useMemo(() => {
|
||
const q = searchTerm.trim().toLowerCase();
|
||
const searchFiltered = q
|
||
? sorted.filter(i =>
|
||
(i.value || '').toLowerCase().includes(q) ||
|
||
(i.name || '').toLowerCase().includes(q) ||
|
||
(i.description || '').toLowerCase().includes(q) ||
|
||
(i.tags || []).some(t => (t || '').toLowerCase().includes(q))
|
||
)
|
||
: sorted;
|
||
return searchFiltered.filter((i) => !showOnlyUsedInFilters || (filterUsage[i.value] || 0) > 0);
|
||
}, [sorted, searchTerm, showOnlyUsedInFilters, filterUsage]);
|
||
|
||
const totalPages = Math.ceil(filtered.length / pageSize) || 1;
|
||
const page = Math.min(currentPage, totalPages);
|
||
const paginated = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||
|
||
const changeSort = (f) => {
|
||
if (sortField === f) setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||
else { setSortField(f); setSortOrder('asc'); }
|
||
};
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="Справочник Community"
|
||
icon={<IconFilter size={24} />}
|
||
/>
|
||
|
||
<div className="card mb-3">
|
||
<div className="card-header py-2 d-flex align-items-center justify-content-between">
|
||
<div className="d-flex align-items-center gap-2">
|
||
<IconPlugConnected size={18} />
|
||
<span className="fw-semibold">Подключение к EvoBGP API</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="btn btn-sm btn-outline-secondary"
|
||
onClick={() => setEvobgpSettingsOpen((o) => !o)}
|
||
>
|
||
{evobgpSettingsOpen ? 'Скрыть' : 'Настроить'}
|
||
</button>
|
||
</div>
|
||
{evobgpSettingsOpen && (
|
||
<div className="card-body">
|
||
<p className="text-secondary small mb-3">
|
||
<strong>По умолчанию</strong> запросы идут на внутренний прокси <code>/api/evobgp</code>: URL и токен задаются в{' '}
|
||
<strong>переменных окружения контейнера</strong> — <code>EVOBGP_API_URL</code> и <code>EVOBGP_API_TOKEN</code>{' '}
|
||
(без CORS). Ниже — только <strong>опциональное</strong> прямое подключение из браузера к EvoBGP; оставьте URL пустым и
|
||
нажмите «Сохранить», чтобы снова использовать прокси.
|
||
</p>
|
||
<div className="row g-2 align-items-end">
|
||
<div className="col-12 col-md-6">
|
||
<label className="form-label">Прямой Base URL EvoBGP (необязательно)</label>
|
||
<input
|
||
type="url"
|
||
className="form-control"
|
||
placeholder="оставьте пустым для прокси"
|
||
autoComplete="off"
|
||
value={evobgpUrlDraft}
|
||
onChange={(e) => setEvobgpUrlDraft(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-4">
|
||
<label className="form-label">Токен (только для прямого режима)</label>
|
||
<input
|
||
type="password"
|
||
className="form-control"
|
||
placeholder="Bearer token"
|
||
autoComplete="off"
|
||
value={evobgpTokenDraft}
|
||
onChange={(e) => setEvobgpTokenDraft(e.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="col-12 col-md-2">
|
||
<button type="button" className="btn btn-primary w-100" onClick={saveEvobgpConnection}>
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Уведомления */}
|
||
{error && (
|
||
<ErrorAlert message={error} onClose={() => setError('')} onRetry={fetchItems} />
|
||
)}
|
||
{success && (
|
||
<div className="alert alert-success alert-dismissible" role="alert">
|
||
<div className="d-flex">
|
||
<div>
|
||
<IconCheck className="icon alert-icon" />
|
||
</div>
|
||
<div>
|
||
{success}
|
||
</div>
|
||
</div>
|
||
<button type="button" className="btn-close" onClick={() => setSuccess('')}></button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Табы и кнопки в одной строке, единый стиль btn-primary */}
|
||
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||
<div className="btn-group" role="tablist" aria-label="Переключение режимов страницы">
|
||
<button
|
||
type="button"
|
||
className={`btn ${activeTab === 'list' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||
onClick={() => setActiveTab('list')}
|
||
role="tab"
|
||
aria-pressed={activeTab === 'list'}
|
||
>
|
||
<IconList size={16} className="me-1 d-none d-sm-inline" />
|
||
Список
|
||
<span
|
||
className={`badge ms-1 ${
|
||
activeTab === 'list'
|
||
? 'bg-white text-primary'
|
||
: 'bg-primary-lt text-primary'
|
||
}`}
|
||
>
|
||
{filtered.length}
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn ${activeTab === 'stats' ? 'btn-primary' : 'btn-outline-primary'}`}
|
||
onClick={() => setActiveTab('stats')}
|
||
role="tab"
|
||
aria-pressed={activeTab === 'stats'}
|
||
>
|
||
<IconChartBar size={16} className="me-1 d-none d-sm-inline" />
|
||
Статистика
|
||
<span
|
||
className={`badge ms-1 ${
|
||
activeTab === 'stats'
|
||
? 'bg-white text-primary'
|
||
: 'bg-primary-lt text-primary'
|
||
}`}
|
||
>
|
||
{items.length}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<div className="btn-group">
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={fetchItems}
|
||
disabled={loading}
|
||
title="Обновить данные"
|
||
>
|
||
<IconRefresh size={16} className={loading ? 'spin me-1' : 'me-1'} />
|
||
Обновить
|
||
</button>
|
||
<PortalDropdown
|
||
buttonClassName="btn btn-primary dropdown-toggle"
|
||
buttonProps={{ title: 'Импорт/Экспорт' }}
|
||
align="left"
|
||
buttonContent={<><IconDownload className="me-1" /> Импорт/Экспорт</>}
|
||
>
|
||
<button type="button" className="dropdown-item" onClick={handleImport}>
|
||
<IconUpload className="me-1" /> Импорт
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="dropdown-item"
|
||
onClick={handleExport}
|
||
disabled={items.length === 0}
|
||
>
|
||
<IconDownload className="me-1" /> Экспорт
|
||
</button>
|
||
</PortalDropdown>
|
||
<button
|
||
type="button"
|
||
className="btn btn-primary"
|
||
onClick={handleSave}
|
||
disabled={loading}
|
||
title="Сохранить"
|
||
>
|
||
{loading ? (
|
||
<span className="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" />
|
||
) : (
|
||
<IconDeviceFloppy className="me-1" />
|
||
)}
|
||
Сохранить
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Отображение списка communities */}
|
||
{activeTab === 'list' && (
|
||
<div className="card card-flat">
|
||
<div className="card-header">
|
||
<div className="row align-items-center w-100 g-2">
|
||
<div className="col">
|
||
<h3 className="card-title mb-0">
|
||
<IconFilter className="icon me-2" size={20} />
|
||
Список community
|
||
<span className="badge bg-blue-lt text-blue ms-2">{filtered.length}</span>
|
||
</h3>
|
||
</div>
|
||
<div className="col-auto">
|
||
<button
|
||
className="btn btn-primary"
|
||
onClick={() => setAddModalOpen(true)}
|
||
>
|
||
<IconPlus className="icon me-1" />
|
||
Добавить community
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="card-body">
|
||
<div className="d-flex gap-2 flex-wrap mb-3 align-items-center">
|
||
<SavedFilters
|
||
pageKey="communities"
|
||
currentFilters={{ searchTerm, sortField, sortOrder }}
|
||
onApplyFilter={(filters) => {
|
||
if (filters.searchTerm !== undefined) setSearchTerm(filters.searchTerm);
|
||
if (filters.sortField !== undefined) setSortField(filters.sortField);
|
||
if (filters.sortOrder !== undefined) setSortOrder(filters.sortOrder);
|
||
setCurrentPage(1);
|
||
}}
|
||
/>
|
||
<div className="input-icon flex-grow-1" style={{ minWidth: '200px', position: 'relative' }}>
|
||
<span className="input-icon-addon"><IconSearch size={18} /></span>
|
||
<input
|
||
className="form-control"
|
||
placeholder="Поиск по value, названию, описанию и тегам"
|
||
value={searchTerm}
|
||
onChange={(e)=>{setSearchTerm(e.target.value); setCurrentPage(1);}}
|
||
style={{ paddingRight: searchTerm ? '2.5rem' : undefined }}
|
||
/>
|
||
{!!searchTerm && (
|
||
<button
|
||
type="button"
|
||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||
style={{ position: 'absolute', right: '0.25rem', top: '50%', transform: 'translateY(-50%)', zIndex: 2 }}
|
||
aria-label="Очистить поиск"
|
||
onClick={()=>{ setSearchTerm(''); setCurrentPage(1); }}
|
||
>
|
||
<IconX size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="d-flex align-items-center gap-3 flex-wrap">
|
||
<label className="form-check form-switch mb-0 d-flex align-items-center gap-2">
|
||
<input
|
||
className="form-check-input"
|
||
type="checkbox"
|
||
checked={showOnlyUsedInFilters}
|
||
onChange={(e) => { setShowOnlyUsedInFilters(e.target.checked); setCurrentPage(1); }}
|
||
/>
|
||
<span className="form-check-label">Только используемые в фильтрах</span>
|
||
{filterUsageLoading && (
|
||
<span className="spinner-border spinner-border-sm text-primary" role="status" aria-hidden="true"></span>
|
||
)}
|
||
</label>
|
||
<button
|
||
className="btn btn-outline-secondary btn-sm"
|
||
type="button"
|
||
onClick={fetchFilterUsage}
|
||
disabled={filterUsageLoading}
|
||
title="Обновить использование из /filters"
|
||
>
|
||
<IconRefresh className="icon me-1" size={16} />
|
||
Обновить использование
|
||
</button>
|
||
{filterUsageError && (
|
||
<span className="text-danger small">{filterUsageError}</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="table-responsive">
|
||
<table className="table card-table table-vcenter table-nowrap mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ width: '8%' }}>Иконка</th>
|
||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('value')}>
|
||
<div className="d-flex align-items-center">
|
||
<IconHash size={16} className="me-1 text-muted" />
|
||
Value
|
||
{sortField==='value' && (
|
||
<span className="ms-1">
|
||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th className="cursor-pointer user-select-none" onClick={()=>changeSort('name')}>
|
||
<div className="d-flex align-items-center">
|
||
<IconFilter size={16} className="me-1 text-muted" />
|
||
Название
|
||
{sortField==='name' && (
|
||
<span className="ms-1">
|
||
{sortOrder==='asc' ? <IconSortAscending size={14} /> : <IconSortDescending size={14} />}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th>Описание</th>
|
||
<th>Теги</th>
|
||
<th style={{ width: '10%' }}>Исп. в фильтрах</th>
|
||
<th className="text-end">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? (
|
||
<tr><td colSpan={7} className="text-center text-muted py-4">Загрузка...</td></tr>
|
||
) : paginated.length === 0 ? (
|
||
<tr><td colSpan={7} className="text-center text-muted py-4">
|
||
<EmptyState
|
||
title="Нет community"
|
||
description="Добавьте запись, чтобы начать."
|
||
action={
|
||
<button className="btn btn-primary" onClick={() => setAddModalOpen(true)}>
|
||
<IconPlus className="me-2" />
|
||
Добавить
|
||
</button>
|
||
}
|
||
/>
|
||
</td></tr>
|
||
) : paginated.map((item) => {
|
||
const usageCount = filterUsage[item.value] || 0;
|
||
const iconInfo = item.icon ? getIconById(item.icon) : null;
|
||
return (
|
||
<tr key={item.value}>
|
||
<td>
|
||
{iconInfo ? (
|
||
<span className="avatar avatar-sm bg-blue-lt text-blue border">
|
||
<iconInfo.Icon size={20} stroke={1.5} />
|
||
</span>
|
||
) : (
|
||
<span className="text-muted">—</span>
|
||
)}
|
||
</td>
|
||
<td>
|
||
<code className="text-blue">{item.value}</code>
|
||
{item._external && (
|
||
<span className="badge bg-yellow-lt text-yellow ms-2" title="Не внесён в справочник">не в справ.</span>
|
||
)}
|
||
</td>
|
||
<td>{item.name || <span className="text-muted">—</span>}</td>
|
||
<td>
|
||
<span className="text-muted" title={item.description}>{item.description || '—'}</span>
|
||
</td>
|
||
<td>
|
||
{(item.tags || []).map(t => (
|
||
<span key={t} className="badge bg-blue-lt text-blue me-1">{t}</span>
|
||
))}
|
||
</td>
|
||
<td>
|
||
{usageCount > 0 ? (
|
||
<span className="badge bg-blue-lt text-blue">{usageCount} в фильтрах</span>
|
||
) : (
|
||
<span className="text-muted">0</span>
|
||
)}
|
||
</td>
|
||
<td className="text-end">
|
||
<div className="btn-list">
|
||
<button
|
||
className="btn btn-outline-primary btn-icon btn-sm"
|
||
onClick={()=>startEdit(item)}
|
||
title="Редактировать"
|
||
>
|
||
<IconEdit size={16} />
|
||
</button>
|
||
<button
|
||
className="btn btn-outline-danger btn-icon btn-sm"
|
||
onClick={()=>handleDeleteClick(item)}
|
||
title="Удалить"
|
||
>
|
||
<IconTrash size={16} />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{totalPages > 1 && (
|
||
<Pagination
|
||
currentPage={page}
|
||
totalPages={totalPages}
|
||
totalItems={filtered.length}
|
||
pageSize={pageSize}
|
||
onPageChange={setCurrentPage}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Отображение статистики communities */}
|
||
{activeTab === 'stats' && (
|
||
<CommunityStats communities={items} />
|
||
)}
|
||
{/* Модальное окно добавления */}
|
||
<FormModal
|
||
show={addModalOpen}
|
||
onClose={() => {
|
||
setAddModalOpen(false);
|
||
setNewItem({ value: '', name: '', description: '', tags: '', color: '', icon: '' });
|
||
setError('');
|
||
}}
|
||
onSubmit={addItem}
|
||
title="Добавить community"
|
||
submitLabel="Добавить"
|
||
submitIcon={IconPlus}
|
||
size="md"
|
||
>
|
||
{error && (
|
||
<div className="alert alert-danger mb-3" role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<FormField
|
||
label="Value"
|
||
name="value"
|
||
value={newItem.value}
|
||
onChange={(val) => setNewItem({...newItem, value: val})}
|
||
placeholder="65001:200 или 200 (без указания AS)"
|
||
required
|
||
icon={IconHash}
|
||
helpText="Формат: число (например, 200) или N:N (например, 65001:200). Без ASN автоматически станет 65001:XXX."
|
||
onValidate={(val) => {
|
||
const trimmed = String(val || '').trim();
|
||
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
|
||
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: 200 или 65001:200' };
|
||
const normalized = normalizeCommunityValue(trimmed);
|
||
if (items.some(i => i.value === normalized)) return { valid: false, message: 'Такое value уже существует' };
|
||
return { valid: true, message: `Будет сохранено как ${normalized}` };
|
||
}}
|
||
/>
|
||
|
||
<FormField
|
||
label="Название"
|
||
name="name"
|
||
value={newItem.name}
|
||
onChange={(val) => setNewItem({...newItem, name: val})}
|
||
placeholder="Напр. Social"
|
||
icon={IconFilter}
|
||
/>
|
||
|
||
<FormField
|
||
label="Описание"
|
||
name="description"
|
||
type="textarea"
|
||
value={newItem.description}
|
||
onChange={(val) => setNewItem({...newItem, description: val})}
|
||
rows={2}
|
||
/>
|
||
|
||
<FormField
|
||
label="Теги (через запятую)"
|
||
name="tags"
|
||
value={newItem.tags}
|
||
onChange={(val) => setNewItem({...newItem, tags: val})}
|
||
placeholder="video,bank"
|
||
helpText="Разделяйте теги запятыми"
|
||
/>
|
||
|
||
<FormField
|
||
label="Цвет бейджа (опционально)"
|
||
name="color"
|
||
value={newItem.color}
|
||
onChange={(val) => setNewItem({...newItem, color: val})}
|
||
placeholder="blue, green, red ..."
|
||
helpText="Цвет для отображения бейджа в интерфейсе"
|
||
/>
|
||
<IconPicker
|
||
value={newItem.icon}
|
||
onChange={(id) => setNewItem({...newItem, icon: id})}
|
||
label="Иконка бренда (опционально)"
|
||
allowNone
|
||
/>
|
||
</FormModal>
|
||
|
||
{/* Модальное окно редактирования */}
|
||
<FormModal
|
||
show={editModalOpen}
|
||
onClose={cancelEdit}
|
||
onSubmit={saveEdit}
|
||
title="Редактировать community"
|
||
submitLabel="Сохранить"
|
||
submitIcon={IconCheck}
|
||
size="md"
|
||
>
|
||
{error && (
|
||
<div className="alert alert-danger mb-3" role="alert">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<FormField
|
||
label="Value"
|
||
name="value"
|
||
value={editingDraft.value}
|
||
onChange={(val) => setEditingDraft({...editingDraft, value: val})}
|
||
placeholder="65001:200 или 200 (без указания AS)"
|
||
required
|
||
icon={IconHash}
|
||
helpText="Формат: число (например, 200) или N:N (например, 65001:200). Без ASN автоматически станет 65001:XXX."
|
||
onValidate={(val) => {
|
||
const trimmed = String(val || '').trim();
|
||
if (!trimmed) return { valid: false, message: 'Поле обязательно' };
|
||
if (!/^(\d+|\d+:\d+)$/.test(trimmed)) return { valid: false, message: 'Формат: 200 или 65001:200' };
|
||
const normalized = normalizeCommunityValue(trimmed);
|
||
if (normalized !== editingValue && items.some(i => i.value === normalized)) {
|
||
return { valid: false, message: 'Такое value уже существует' };
|
||
}
|
||
return { valid: true, message: `Будет сохранено как ${normalized}` };
|
||
}}
|
||
/>
|
||
|
||
<FormField
|
||
label="Название"
|
||
name="name"
|
||
value={editingDraft.name}
|
||
onChange={(val) => setEditingDraft({...editingDraft, name: val})}
|
||
placeholder="Напр. Social"
|
||
icon={IconFilter}
|
||
/>
|
||
|
||
<FormField
|
||
label="Описание"
|
||
name="description"
|
||
type="textarea"
|
||
value={editingDraft.description}
|
||
onChange={(val) => setEditingDraft({...editingDraft, description: val})}
|
||
rows={2}
|
||
/>
|
||
|
||
<FormField
|
||
label="Теги (через запятую)"
|
||
name="tags"
|
||
value={editingDraft.tags}
|
||
onChange={(val) => setEditingDraft({...editingDraft, tags: val})}
|
||
placeholder="video,bank"
|
||
helpText="Разделяйте теги запятыми"
|
||
/>
|
||
|
||
<FormField
|
||
label="Цвет бейджа (опционально)"
|
||
name="color"
|
||
value={editingDraft.color}
|
||
onChange={(val) => setEditingDraft({...editingDraft, color: val})}
|
||
placeholder="blue, green, red ..."
|
||
helpText="Цвет для отображения бейджа в интерфейсе"
|
||
/>
|
||
<IconPicker
|
||
value={editingDraft.icon}
|
||
onChange={(id) => setEditingDraft({...editingDraft, icon: id})}
|
||
label="Иконка бренда (опционально)"
|
||
allowNone
|
||
/>
|
||
</FormModal>
|
||
|
||
{/* Модальное окно подтверждения удаления */}
|
||
<ConfirmModal
|
||
show={deleteModalOpen}
|
||
onClose={() => {
|
||
setDeleteModalOpen(false);
|
||
setItemToDelete(null);
|
||
}}
|
||
onConfirm={confirmDelete}
|
||
title="Удалить community?"
|
||
message={
|
||
itemToDelete ? (
|
||
<>
|
||
<p>Вы уверены, что хотите удалить community <strong><code>{itemToDelete.value}</code></strong>?</p>
|
||
{itemToDelete.name && (
|
||
<p className="text-muted mb-0">Название: {itemToDelete.name}</p>
|
||
)}
|
||
<p className="text-muted mb-0 mt-2">Это действие необратимо.</p>
|
||
</>
|
||
) : null
|
||
}
|
||
confirmLabel="Удалить"
|
||
variant="danger"
|
||
icon={IconTrash}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default CommunitiesManager;
|
||
|
||
|