feat: Обновление компонента CommunitiesManager с добавлением новых иконок, улучшением валидации форм и стилизации. Внедрены сохраненные фильтры для более удобного поиска и сортировки, а также улучшена навигация по страницам.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m38s

This commit is contained in:
2025-11-11 19:48:27 +07:00
parent b34bf4af95
commit c83a4dcd81
+91 -33
View File
@@ -15,7 +15,9 @@ import {
IconHash,
IconFilter,
IconChartBar,
IconList
IconList,
IconArrowUp,
IconArrowDown
} from '@tabler/icons-react';
import PageHeaderActions from './components/PageHeaderActions.jsx';
import PageHeader from './components/PageHeader.jsx';
@@ -23,6 +25,7 @@ import EmptyState from './components/EmptyState.jsx';
import { TableEmpty } from './components/TableSkeleton.jsx';
import Breadcrumbs from './components/Breadcrumbs.jsx';
import CommunityStats from './components/CommunityStats.jsx';
import SavedFilters from './components/SavedFilters.jsx';
const API_URL = '/api';
@@ -298,16 +301,24 @@ function CommunitiesManager() {
{activeTab === 'list' && (
<div className="row g-3">
<div className="col-lg-3">
<div className="card card-md">
<div className="card-header"><h3 className="card-title"><IconPlus className="me-2" />Добавить</h3></div>
<div className="card card-md animate-in">
<div className="card-header bg-primary-lt"><h3 className="card-title text-primary"><IconPlus className="me-2" />Добавить</h3></div>
<div className="card-body">
<div className="mb-2">
<label className="form-label">Value</label>
<input className="form-control" value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
<label className="form-label required">Value</label>
<div className="input-icon">
<span className="input-icon-addon"><IconHash size={18} /></span>
<input className={`form-control${newItem.value && !/^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) ? ' is-invalid' : (newItem.value ? ' is-valid' : '')}`} value={newItem.value} onChange={(e)=>setNewItem({...newItem, value:e.target.value})} placeholder="65001:200 или 100" />
</div>
{newItem.value && !/^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) && (<div className="invalid-feedback">Формат: число или N:N</div>)}
{newItem.value && /^(\d+|\d+:\d+)$/.test(String(newItem.value).trim()) && (<div className="valid-feedback">Корректно</div>)}
</div>
<div className="mb-2">
<label className="form-label">Название</label>
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
<div className="input-icon">
<span className="input-icon-addon"><IconFilter size={18} /></span>
<input className="form-control" value={newItem.name} onChange={(e)=>setNewItem({...newItem, name:e.target.value})} placeholder="Напр. Social" />
</div>
</div>
<div className="mb-2">
<label className="form-label">Описание</label>
@@ -325,7 +336,7 @@ function CommunitiesManager() {
<label className="form-label">Цвет бейджа (опц.)</label>
<input className="form-control" value={newItem.color} onChange={(e)=>setNewItem({...newItem, color:e.target.value})} placeholder="blue, green, red ..." />
</div>
<button className="btn btn-primary w-100" onClick={addItem}><IconPlus className="me-2" />Добавить</button>
<button className="btn btn-primary w-100" onClick={addItem} disabled={!/^(\d+|\d+:\d+)$/.test(String(newItem.value || '').trim())}><IconPlus className="me-2" />Добавить</button>
</div>
</div>
@@ -333,20 +344,55 @@ function CommunitiesManager() {
</div>
<div className="col-lg-9">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h3 className="card-title mb-0">Список community <span className="badge bg-blue-lt text-blue ms-2">{items.length}</span></h3>
<div className="input-icon w-50">
<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);}} />
<div className="card animate-in" style={{animationDelay: '0.2s'}}>
<div className="card-header">
<div className="row align-items-center w-100 g-2">
<div className="col-auto">
<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">
<div className="d-flex gap-2 flex-wrap">
<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>
</div>
</div>
</div>
<div className="table-responsive">
<table className="table card-table table-vcenter table-nowrap mb-0">
<thead>
<tr>
<th className="cursor-pointer" onClick={()=>changeSort('value')}>Value {sortField==='value' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</th>
<th className="cursor-pointer" onClick={()=>changeSort('name')}>Название {sortField==='name' && <span className="ms-1">{sortOrder==='asc'?'▲':'▼'}</span>}</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'? <IconArrowUp size={14} /> : <IconArrowDown 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'? <IconArrowUp size={14} /> : <IconArrowDown size={14} />}</span>}
</div>
</th>
<th>Описание</th>
<th>Теги</th>
<th>Gateway</th>
@@ -360,8 +406,8 @@ function CommunitiesManager() {
<tr><td colSpan={6} className="text-center text-muted py-4">
<EmptyState title="Нет community" description="Добавьте запись, чтобы начать." action={<button className="btn btn-primary" onClick={addItem}>Добавить</button>} />
</td></tr>
) : paginated.map((item) => (
<tr key={item.value} className={editingValue === item.value ? 'table-info' : ''}>
) : paginated.map((item, idx) => (
<tr key={item.value} className={editingValue === item.value ? 'table-active' : ''} style={{ animation: `fadeIn 0.3s ease ${idx * 0.02}s both` }}>
<td>
{editingValue === item.value ? (
<input className="form-control form-control-sm" value={editingDraft.value} onChange={(e)=>setEditingDraft({...editingDraft, value:e.target.value})} ref={editRef} />
@@ -419,22 +465,34 @@ function CommunitiesManager() {
</table>
</div>
{totalPages > 1 && (
<div className="card-footer d-flex align-items-center justify-content-between">
<div className="text-muted">Показано {((page - 1) * pageSize) + 1} - {Math.min(page * pageSize, filtered.length)} из {filtered.length}</div>
<ul className="pagination m-0">
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1}>Первая</button>
</li>
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1}>Назад</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages}>Вперед</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages}>Последняя</button>
</li>
</ul>
<div className="card-footer">
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div className="text-muted small">Показано <strong>{((page - 1) * pageSize) + 1}</strong> - <strong>{Math.min(page * pageSize, filtered.length)}</strong> из <strong>{filtered.length}</strong></div>
<ul className="pagination m-0">
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(1)} disabled={page===1} aria-label="Первая страница">
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
<li className={`page-item${page === 1 ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page-1)} disabled={page===1} aria-label="Предыдущая страница">
<IconArrowUp size={14} style={{ transform: 'rotate(-90deg)' }} />
<span className="d-none d-sm-inline ms-1">Назад</span>
</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(page+1)} disabled={page===totalPages} aria-label="Следующая страница">
<span className="d-none d-sm-inline me-1">Вперед</span>
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
<li className={`page-item${page === totalPages ? ' disabled' : ''}`}>
<button className="page-link" onClick={()=>setCurrentPage(totalPages)} disabled={page===totalPages} aria-label="Последняя страница">
<IconArrowDown size={14} style={{ transform: 'rotate(-90deg)' }} />
</button>
</li>
</ul>
</div>
</div>
)}
</div>