feat(ServerGroupCard): enhance server group card layout and functionality with improved selection and expanded view
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
IconServer,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconRouter,
|
||||
IconCircleCheck,
|
||||
IconSquare
|
||||
IconSquare,
|
||||
IconNetwork,
|
||||
IconCloud
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -23,8 +24,8 @@ function countryToFlag(isoCode) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Одна карточка для группы серверов (например два home-роутера с одним groupKey — мультиван).
|
||||
* Каждый сервер в группе сохраняет свой id; отображаются объединённо.
|
||||
* Карточка группы серверов в том же формате, что и одиночная ServerCard:
|
||||
* слева бейдж HOME, центр — флаг, название, провайдер и DNS с тегами, справа кнопки.
|
||||
*/
|
||||
export default function ServerGroupCard({
|
||||
groupKey,
|
||||
@@ -34,93 +35,171 @@ export default function ServerGroupCard({
|
||||
onEdit,
|
||||
onDelete
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const title = groupKey || (servers[0]?.dns?.split('.')[0] || 'Группа');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const first = servers[0];
|
||||
const title = groupKey || (first?.dns?.split('.')[0] || 'Группа');
|
||||
const provider = first?.provider || '';
|
||||
const tunnel = first?.tunnel;
|
||||
const wanCount = servers.length;
|
||||
const anySelected = servers.some((s) => selectedSet.has(s.ip));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="server-group-card card mb-2 border-primary-lt"
|
||||
className={`server-group-card card mb-2 ${anySelected ? 'border-primary' : ''}`}
|
||||
style={{
|
||||
borderRadius: '12px',
|
||||
borderWidth: '1px',
|
||||
borderLeftWidth: '4px',
|
||||
borderLeftColor: 'var(--tblr-primary)'
|
||||
borderWidth: anySelected ? '2px' : '1px',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: anySelected ? '0 0 0 3px rgba(32, 107, 196, 0.15)' : 'none'
|
||||
}}
|
||||
>
|
||||
<div className="card-body p-0">
|
||||
{/* Заголовок группы */}
|
||||
<div
|
||||
className="d-flex align-items-center px-3 py-2 bg-primary-lt"
|
||||
style={{
|
||||
borderTopLeftRadius: '11px',
|
||||
borderTopRightRadius: '11px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<IconRouter size={20} className="text-primary me-2" />
|
||||
<span className="fw-semibold text-primary">{title}</span>
|
||||
<span className="text-muted ms-2">({servers.length} WAN)</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm ms-auto"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||
title={expanded ? 'Свернуть' : 'Развернуть'}
|
||||
{/* Основная строка — как у ServerCard */}
|
||||
<div className="d-flex align-items-stretch">
|
||||
{/* Левая часть: бейдж HOME */}
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center px-3 bg-green-lt"
|
||||
style={{
|
||||
minWidth: '80px',
|
||||
borderTopLeftRadius: anySelected ? '10px' : '11px',
|
||||
borderBottomLeftRadius: expanded ? '0' : (anySelected ? '10px' : '11px'),
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => onSelect?.(first)}
|
||||
>
|
||||
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="mb-1">
|
||||
{anySelected ? (
|
||||
<IconCircleCheck size={20} className="text-green" />
|
||||
) : (
|
||||
<IconRouter size={20} className="text-green" />
|
||||
)}
|
||||
</div>
|
||||
<div className="fw-bold text-green" style={{ fontSize: '0.65rem', letterSpacing: '0.5px' }}>
|
||||
HOME
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Центр: флаг, название, провайдер; ниже DNS и теги */}
|
||||
<div
|
||||
className="flex-grow-1 py-2 px-3"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className="d-flex align-items-center mb-1">
|
||||
<span className="me-2" style={{ fontSize: '1.25rem' }}>{countryToFlag(first?.country)}</span>
|
||||
<div>
|
||||
<span className="fw-semibold">{title}</span>
|
||||
{provider && (
|
||||
<>
|
||||
<span className="text-muted mx-2">•</span>
|
||||
<span className="text-muted">{provider}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="badge bg-success-lt text-success ms-auto">
|
||||
{wanCount} WAN
|
||||
</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<code className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
{first?.dns || first?.ip || ''}
|
||||
</code>
|
||||
{tunnel && (
|
||||
<span className="badge bg-secondary-lt text-secondary">
|
||||
<IconNetwork size={12} className="me-1" />
|
||||
{tunnel}
|
||||
</span>
|
||||
)}
|
||||
<span className="badge bg-green-lt text-green">
|
||||
<IconCloud size={12} className="me-1" />
|
||||
{wanCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Правая часть: кнопки как у необъединённой карточки */}
|
||||
<div className="d-flex align-items-center gap-2 px-3 border-start">
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
title="Редактировать"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(first); }}
|
||||
>
|
||||
<IconEdit size={16} className="me-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
title="Удалить"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(first); }}
|
||||
>
|
||||
<IconTrash size={16} className="me-1" />
|
||||
Удалить
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon ms-1"
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
|
||||
title={expanded ? 'Свернуть' : 'Подробнее'}
|
||||
>
|
||||
{expanded ? <IconChevronUp size={18} /> : <IconChevronDown size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Развёрнутый список WAN */}
|
||||
{expanded && (
|
||||
<div className="p-2">
|
||||
{servers.map((server) => {
|
||||
const isSelected = selectedSet.has(server.ip);
|
||||
const serverName = server.dns?.split('.')[0] || server.ip;
|
||||
return (
|
||||
<div
|
||||
key={server.id || server.ip}
|
||||
className={`d-flex align-items-center rounded p-2 mb-1 ${isSelected ? 'bg-primary-lt' : 'bg-light'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm p-0 me-2"
|
||||
onClick={() => onSelect?.(server)}
|
||||
title={isSelected ? 'Снять выбор' : 'Выбрать'}
|
||||
<div className="border-top bg-light" style={{ borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' }}>
|
||||
<div className="p-2">
|
||||
{servers.map((server) => {
|
||||
const isSelected = selectedSet.has(server.ip);
|
||||
const serverName = server.dns?.split('.')[0] || server.ip;
|
||||
return (
|
||||
<div
|
||||
key={server.id || server.ip}
|
||||
className={`d-flex align-items-center rounded p-2 mb-1 ${isSelected ? 'bg-primary-lt' : ''}`}
|
||||
>
|
||||
{isSelected ? (
|
||||
<IconCircleCheck size={18} className="text-primary" />
|
||||
) : (
|
||||
<IconSquare size={18} className="text-muted" />
|
||||
)}
|
||||
</button>
|
||||
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(server.country)}</span>
|
||||
<div className="flex-grow-1 min-w-0">
|
||||
<span className="fw-medium">{serverName}</span>
|
||||
<span className="text-muted ms-2 small">{server.dns || server.ip}</span>
|
||||
</div>
|
||||
<div className="btn-list gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
title="Редактировать"
|
||||
onClick={() => onEdit?.(server)}
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm p-0 me-2"
|
||||
onClick={() => onSelect?.(server)}
|
||||
title={isSelected ? 'Снять выбор' : 'Выбрать'}
|
||||
>
|
||||
<IconEdit size={14} className="me-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
title="Удалить"
|
||||
onClick={() => onDelete?.(server)}
|
||||
>
|
||||
<IconTrash size={14} className="me-1" />
|
||||
Удалить
|
||||
{isSelected ? (
|
||||
<IconCircleCheck size={18} className="text-primary" />
|
||||
) : (
|
||||
<IconSquare size={18} className="text-muted" />
|
||||
)}
|
||||
</button>
|
||||
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(server.country)}</span>
|
||||
<div className="flex-grow-1 min-w-0">
|
||||
<span className="fw-medium">{serverName}</span>
|
||||
<span className="text-muted ms-2 small">{server.dns || server.ip}</span>
|
||||
</div>
|
||||
<div className="btn-list gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-secondary btn-sm"
|
||||
title="Редактировать"
|
||||
onClick={() => onEdit?.(server)}
|
||||
>
|
||||
<IconEdit size={14} className="me-1" />
|
||||
Изменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
title="Удалить"
|
||||
onClick={() => onDelete?.(server)}
|
||||
>
|
||||
<IconTrash size={14} className="me-1" />
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user