Files
router-lists-ui/frontend/src/CommunitiesManager.jsx
T
denozord b0c8a0a4bb
Publish Docker image / build-and-push (push) Successful in 2m6s
Refactor frontend structure and update dependencies
- 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
2026-04-26 14:56:48 +07:00

938 lines
37 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;