feat: Update ServerCard and ServerSidebar components for improved UI and functionality, including enhanced filtering options, updated country flag handling, and refined layout for better user experience.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m50s
This commit is contained in:
Generated
+44
-22
@@ -11,13 +11,12 @@
|
||||
"@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",
|
||||
"html-to-image": "^1.11.11",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-sigma": "^1.2.35"
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
@@ -28,6 +27,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -660,6 +660,33 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-virtual": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz",
|
||||
"integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/virtual-core": "3.13.18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/virtual-core": {
|
||||
"version": "3.13.18",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"dev": true,
|
||||
@@ -1689,12 +1716,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"dev": true,
|
||||
@@ -2108,19 +2129,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-sigma": {
|
||||
"version": "1.2.35",
|
||||
"resolved": "https://registry.npmjs.org/react-sigma/-/react-sigma-1.2.35.tgz",
|
||||
"integrity": "sha512-amjKjaQusefDp8dD9er3wE/lfubSQT5fJSRF4SsS0LjxRue4bpSapAQ1WpmSM6nQEgawbrKel+bQBmatl7MAIw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=15.3",
|
||||
"react-dom": ">=15.3"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -2216,6 +2224,20 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"dev": true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
IconServer,
|
||||
IconWorld,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconLink,
|
||||
@@ -8,18 +7,27 @@ import {
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconNetwork,
|
||||
IconMapPin,
|
||||
IconCheck,
|
||||
IconSquare,
|
||||
IconSquareCheck
|
||||
IconCircleCheck,
|
||||
IconExternalLink,
|
||||
IconRefresh
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
// Преобразование кода страны в emoji-флаг
|
||||
function countryToFlag(isoCode) {
|
||||
if (!isoCode) return '';
|
||||
return isoCode
|
||||
.toUpperCase()
|
||||
const codeMap = {
|
||||
'SWE': 'SE',
|
||||
'RUS': 'RU',
|
||||
'USA': 'US',
|
||||
'GER': 'DE',
|
||||
'FIN': 'FI',
|
||||
'NLD': 'NL'
|
||||
};
|
||||
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
|
||||
if (!code || code.length !== 2) return isoCode;
|
||||
return code
|
||||
.replace(/./g, char =>
|
||||
String.fromCodePoint(127397 + char.charCodeAt())
|
||||
);
|
||||
@@ -32,147 +40,279 @@ export default function ServerCard({
|
||||
onEdit,
|
||||
onDelete,
|
||||
onGenerateLink,
|
||||
onQuickCopy,
|
||||
compact = false
|
||||
onQuickCopy
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const isExit = server.type === 'exit';
|
||||
const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '—';
|
||||
const primaryGateway = server.gateways?.find(g => g.primary)?.name || server.gateway || '';
|
||||
const connectionsCount = server.gateways?.length || 0;
|
||||
const shortName = server.dns?.split('.')[0] || server.ip?.split('.').slice(0, 2).join('.');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`server-card card mb-2 ${selected ? 'border-primary' : ''}`}
|
||||
className="server-row mb-2"
|
||||
style={{
|
||||
borderWidth: selected ? '2px' : '1px',
|
||||
transition: 'all 0.15s ease'
|
||||
borderRadius: '10px',
|
||||
overflow: 'hidden',
|
||||
background: selected ? 'rgba(32, 107, 196, 0.04)' : '#fff',
|
||||
border: selected ? '2px solid #206bc4' : '1px solid rgba(0,0,0,0.08)',
|
||||
transition: 'all 0.15s ease',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<div className="card-body p-3">
|
||||
{/* Верхняя строка: checkbox + IP + тип + actions */}
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
{/* Чекбокс выбора */}
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm p-0"
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(server); }}
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
>
|
||||
{selected ? (
|
||||
<IconSquareCheck size={18} className="text-primary" />
|
||||
) : (
|
||||
<IconSquare size={18} className="text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{/* Основная строка - всё в одну линию */}
|
||||
<div
|
||||
className="d-flex align-items-center px-3"
|
||||
style={{ height: '52px' }}
|
||||
>
|
||||
{/* Статус онлайн */}
|
||||
<div
|
||||
className="rounded-circle flex-shrink-0"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
background: '#22c55e',
|
||||
marginRight: '12px'
|
||||
}}
|
||||
title="Онлайн"
|
||||
/>
|
||||
|
||||
{/* IP адрес */}
|
||||
<div>
|
||||
<code className="fs-5 fw-bold">{server.ip}</code>
|
||||
</div>
|
||||
{/* Чекбокс выбора */}
|
||||
<button
|
||||
className="btn p-0 border-0 bg-transparent me-3 flex-shrink-0"
|
||||
onClick={(e) => { e.stopPropagation(); onSelect?.(server); }}
|
||||
style={{ lineHeight: 1 }}
|
||||
>
|
||||
{selected ? (
|
||||
<IconCircleCheck size={20} className="text-primary" />
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
border: '2px solid #d1d5db',
|
||||
borderRadius: '50%'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Тип */}
|
||||
<span className={`badge ${isExit ? 'bg-red-lt text-danger' : 'bg-blue-lt text-blue'}`}>
|
||||
{isExit ? 'Exit' : 'Jumphost'}
|
||||
</span>
|
||||
|
||||
{/* Страна */}
|
||||
<span className="badge bg-secondary-lt">
|
||||
{countryToFlag(server.country)} {server.country}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Действия */}
|
||||
<div className="btn-list gap-1 mb-0">
|
||||
<button
|
||||
className="btn btn-ghost-primary btn-icon btn-sm"
|
||||
title="Скопировать ссылку"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickCopy?.(server); }}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-primary btn-icon btn-sm"
|
||||
title="Генератор ссылок"
|
||||
onClick={(e) => { e.stopPropagation(); onGenerateLink?.(server); }}
|
||||
>
|
||||
<IconLink size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||
title="Редактировать"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-danger btn-icon btn-sm"
|
||||
title="Удалить"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
title={expanded ? 'Свернуть' : 'Развернуть'}
|
||||
>
|
||||
{expanded ? <IconChevronUp size={16} /> : <IconChevronDown size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
{/* Количество соединений */}
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-center flex-shrink-0 me-3"
|
||||
style={{
|
||||
background: 'rgba(32, 107, 196, 0.08)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
minWidth: '38px'
|
||||
}}
|
||||
>
|
||||
<IconNetwork size={13} className="text-primary" style={{ marginRight: 3 }} />
|
||||
<span className="fw-bold text-primary" style={{ fontSize: '0.75rem' }}>{connectionsCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Основная информация */}
|
||||
<div className="d-flex flex-wrap gap-3 mt-2 text-muted small">
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<IconWorld size={14} />
|
||||
<span>{server.dns || '—'}</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<IconMapPin size={14} />
|
||||
<span>{server.provider}</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<IconNetwork size={14} />
|
||||
<span className="badge bg-green-lt text-green small">{server.tunnel}</span>
|
||||
</div>
|
||||
<div className="d-flex align-items-center gap-1">
|
||||
<IconServer size={14} />
|
||||
<span>{primaryGateway}</span>
|
||||
</div>
|
||||
{/* Флаг */}
|
||||
<span
|
||||
className="flex-shrink-0 me-2"
|
||||
style={{ fontSize: '1.15rem', lineHeight: 1 }}
|
||||
>
|
||||
{countryToFlag(server.country)}
|
||||
</span>
|
||||
|
||||
{/* Название */}
|
||||
<span
|
||||
className="fw-semibold me-3 flex-shrink-0"
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
minWidth: '100px',
|
||||
maxWidth: '140px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{shortName}
|
||||
</span>
|
||||
|
||||
{/* Тип */}
|
||||
<span
|
||||
className={`badge flex-shrink-0 me-3 ${isExit ? 'bg-danger' : 'bg-primary'}`}
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '4px',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.3px'
|
||||
}}
|
||||
>
|
||||
{isExit ? 'EXIT' : 'JUMP'}
|
||||
</span>
|
||||
|
||||
{/* Провайдер */}
|
||||
<span
|
||||
className="badge flex-shrink-0 me-3"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(0,0,0,0.06)',
|
||||
color: '#374151',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{server.provider}
|
||||
</span>
|
||||
|
||||
{/* Туннель */}
|
||||
<span
|
||||
className="badge flex-shrink-0 me-3"
|
||||
style={{
|
||||
fontSize: '0.65rem',
|
||||
padding: '3px 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(20, 184, 166, 0.12)',
|
||||
color: '#0d9488',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
{server.tunnel}
|
||||
</span>
|
||||
|
||||
{/* DNS */}
|
||||
<div className="d-flex align-items-center text-muted flex-grow-1 me-2" style={{ minWidth: 0 }}>
|
||||
<IconExternalLink size={13} style={{ opacity: 0.4, flexShrink: 0 }} className="me-1" />
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{server.dns}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Расширенная информация */}
|
||||
{expanded && (
|
||||
<div className="mt-3 pt-3 border-top">
|
||||
<div className="row g-2">
|
||||
<div className="col-md-6">
|
||||
<div className="text-muted small mb-1">Внешний IP</div>
|
||||
<code>{server.extIp || '—'}</code>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<div className="text-muted small mb-1">Внутренний IP</div>
|
||||
<code>{server.internalIp || '—'}</code>
|
||||
</div>
|
||||
{server.gateways?.length > 0 && (
|
||||
<div className="col-12 mt-2">
|
||||
<div className="text-muted small mb-1">Все шлюзы</div>
|
||||
<div className="d-flex flex-wrap gap-1">
|
||||
{server.gateways.map((gw, idx) => (
|
||||
<span
|
||||
key={gw.id || idx}
|
||||
className={`badge ${gw.primary ? 'bg-primary' : 'bg-secondary-lt'}`}
|
||||
title={gw.comment || (gw.primary ? 'Основной шлюз' : 'Резервный шлюз')}
|
||||
>
|
||||
{gw.name}{gw.ip ? ` (${gw.ip})` : ''}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Gateway */}
|
||||
{primaryGateway && (
|
||||
<span
|
||||
className="badge flex-shrink-0 me-3"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
background: 'rgba(34, 197, 94, 0.1)',
|
||||
color: '#16a34a',
|
||||
fontWeight: 500
|
||||
}}
|
||||
>
|
||||
<IconServer size={11} className="me-1" style={{ marginTop: -1 }} />
|
||||
{primaryGateway}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Действия */}
|
||||
<div className="d-flex align-items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
className="btn btn-ghost-primary btn-sm p-1"
|
||||
title="Скопировать ссылку"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickCopy?.(server); }}
|
||||
style={{ width: 28, height: 28, borderRadius: '6px' }}
|
||||
>
|
||||
<IconCopy size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-sm p-1"
|
||||
title="Генератор ссылок"
|
||||
onClick={(e) => { e.stopPropagation(); onGenerateLink?.(server); }}
|
||||
style={{ width: 28, height: 28, borderRadius: '6px' }}
|
||||
>
|
||||
<IconLink size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-sm p-1"
|
||||
title="Редактировать"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit?.(server); }}
|
||||
style={{ width: 28, height: 28, borderRadius: '6px' }}
|
||||
>
|
||||
<IconEdit size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-danger btn-sm p-1"
|
||||
title="Удалить"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete?.(server); }}
|
||||
style={{ width: 28, height: 28, borderRadius: '6px' }}
|
||||
>
|
||||
<IconTrash size={15} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-sm p-1"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
title={expanded ? 'Свернуть' : 'Развернуть'}
|
||||
style={{ width: 28, height: 28, borderRadius: '6px' }}
|
||||
>
|
||||
{expanded ? <IconChevronUp size={15} /> : <IconChevronDown size={15} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Расширенная информация */}
|
||||
{expanded && (
|
||||
<div
|
||||
className="px-4 py-3"
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.015)',
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)'
|
||||
}}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-md-3">
|
||||
<div className="text-muted small mb-1" style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>IP адрес</div>
|
||||
<code style={{ fontSize: '0.85rem' }}>{server.ip}</code>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="text-muted small mb-1" style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Внешний IP</div>
|
||||
<code style={{ fontSize: '0.85rem' }}>{server.extIp || '—'}</code>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="text-muted small mb-1" style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Внутренний IP</div>
|
||||
<code style={{ fontSize: '0.85rem' }}>{server.internalIp || '—'}</code>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<div className="text-muted small mb-1" style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Страна</div>
|
||||
<span style={{ fontSize: '0.85rem' }}>{countryToFlag(server.country)} {server.country}</span>
|
||||
</div>
|
||||
{server.gateways?.length > 0 && (
|
||||
<div className="col-12">
|
||||
<div className="text-muted small mb-2" style={{ fontSize: '0.7rem', textTransform: 'uppercase', letterSpacing: '0.5px' }}>
|
||||
Шлюзы ({server.gateways.length})
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-2">
|
||||
{server.gateways.map((gw, idx) => (
|
||||
<span
|
||||
key={gw.id || idx}
|
||||
className={`badge ${gw.primary ? 'bg-primary' : ''}`}
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '5px 10px',
|
||||
background: gw.primary ? undefined : 'rgba(0,0,0,0.06)',
|
||||
color: gw.primary ? undefined : '#374151'
|
||||
}}
|
||||
title={gw.comment || (gw.primary ? 'Основной' : 'Резервный')}
|
||||
>
|
||||
{gw.primary && <IconCheck size={11} className="me-1" />}
|
||||
{gw.name}
|
||||
{gw.ip && <span className="ms-1 opacity-75">({gw.ip})</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,18 +6,26 @@ import {
|
||||
IconNetwork,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconCheck,
|
||||
IconX,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconRefresh
|
||||
IconDatabase
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
// Преобразование кода страны в emoji-флаг
|
||||
function countryToFlag(isoCode) {
|
||||
if (!isoCode) return '';
|
||||
return isoCode
|
||||
.toUpperCase()
|
||||
const codeMap = {
|
||||
'SWE': 'SE',
|
||||
'RUS': 'RU',
|
||||
'USA': 'US',
|
||||
'GER': 'DE',
|
||||
'FIN': 'FI',
|
||||
'NLD': 'NL'
|
||||
};
|
||||
const code = codeMap[isoCode?.toUpperCase()] || isoCode?.toUpperCase();
|
||||
if (!code || code.length !== 2) return isoCode;
|
||||
return code
|
||||
.replace(/./g, char =>
|
||||
String.fromCodePoint(127397 + char.charCodeAt())
|
||||
);
|
||||
@@ -76,220 +84,340 @@ export default function ServerSidebar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="server-sidebar" style={{
|
||||
width: '280px',
|
||||
minWidth: '280px',
|
||||
borderRight: '1px solid var(--tblr-border-color)',
|
||||
height: 'calc(100vh - 180px)',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--tblr-bg-surface)'
|
||||
}}>
|
||||
{/* Заголовок с кнопкой сброса */}
|
||||
<div className="d-flex align-items-center justify-content-between p-3 border-bottom">
|
||||
<div
|
||||
className="server-sidebar d-flex flex-column"
|
||||
style={{
|
||||
width: '260px',
|
||||
minWidth: '260px',
|
||||
background: '#fff',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
|
||||
height: 'fit-content',
|
||||
maxHeight: 'calc(100vh - 200px)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between px-3 py-3"
|
||||
style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconFilter size={18} className="me-2 text-muted" />
|
||||
<IconFilter size={18} className="text-primary me-2" />
|
||||
<span className="fw-semibold">Фильтры</span>
|
||||
{activeFiltersCount > 0 && (
|
||||
<span className="badge bg-primary ms-2">{activeFiltersCount}</span>
|
||||
<span
|
||||
className="badge bg-primary ms-2"
|
||||
style={{ fontSize: '0.7rem', padding: '3px 6px' }}
|
||||
>
|
||||
{activeFiltersCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activeFiltersCount > 0 && (
|
||||
<button
|
||||
className="btn btn-ghost-secondary btn-sm btn-icon"
|
||||
className="btn btn-ghost-secondary btn-icon btn-sm"
|
||||
onClick={onReset}
|
||||
title="Сбросить все фильтры"
|
||||
title="Сбросить"
|
||||
style={{ width: 28, height: 28 }}
|
||||
>
|
||||
<IconX size={16} />
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="p-3 border-bottom">
|
||||
<div className="px-3 py-2" style={{ borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
|
||||
<div className="input-icon">
|
||||
<span className="input-icon-addon">
|
||||
<IconSearch size={16} />
|
||||
<IconSearch size={14} className="text-muted" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder="Поиск серверов..."
|
||||
placeholder="Поиск..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
border: '1px solid rgba(0,0,0,0.06)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
/>
|
||||
{searchTerm && (
|
||||
<span
|
||||
className="input-icon-addon cursor-pointer"
|
||||
onClick={() => onSearch('')}
|
||||
style={{ right: 0 }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Скроллируемый контент */}
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{/* Секция: Страны */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('country')}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconWorld size={15} className="me-2" style={{ color: '#3b82f6' }} />
|
||||
<span className="fw-medium small">Страны</span>
|
||||
</div>
|
||||
{expandedSections.country ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</div>
|
||||
{expandedSections.country && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byCountry)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([country, count]) => (
|
||||
<button
|
||||
key={country}
|
||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.country === country ? 'btn-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleFilterClick('country', country)}
|
||||
style={{
|
||||
background: filters.country === country ? undefined : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 10px',
|
||||
transition: 'background 0.15s'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (filters.country !== country) {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (filters.country !== country) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="d-flex align-items-center">
|
||||
<span className="me-2" style={{ fontSize: '1.1rem' }}>{countryToFlag(country)}</span>
|
||||
<span className={filters.country === country ? 'text-white' : ''}>{country}</span>
|
||||
</span>
|
||||
<span
|
||||
className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Провайдеры */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('provider')}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconBuildingSkyscraper size={15} className="me-2" style={{ color: '#8b5cf6' }} />
|
||||
<span className="fw-medium small">Провайдеры</span>
|
||||
</div>
|
||||
{expandedSections.provider ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</div>
|
||||
{expandedSections.provider && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byProvider)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([provider, count]) => (
|
||||
<button
|
||||
key={provider}
|
||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.provider === provider ? 'btn-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleFilterClick('provider', provider)}
|
||||
style={{
|
||||
background: filters.provider === provider ? undefined : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (filters.provider !== provider) {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (filters.provider !== provider) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={filters.provider === provider ? 'text-white' : ''}>{provider}</span>
|
||||
<span
|
||||
className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Типы */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('type')}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconServer size={15} className="me-2" style={{ color: '#10b981' }} />
|
||||
<span className="fw-medium small">Тип сервера</span>
|
||||
</div>
|
||||
{expandedSections.type ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</div>
|
||||
{expandedSections.type && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => (
|
||||
<button
|
||||
key={type}
|
||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.type === type ? 'btn-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleFilterClick('type', type)}
|
||||
style={{
|
||||
background: filters.type === type ? undefined : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (filters.type !== type) {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (filters.type !== type) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="d-flex align-items-center">
|
||||
<span
|
||||
className="rounded-circle me-2"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: type === 'exit' ? '#ef4444' : '#3b82f6'
|
||||
}}
|
||||
/>
|
||||
<span className={filters.type === type ? 'text-white' : ''}>
|
||||
{typeLabels[type] || type}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Туннели */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('tunnel')}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.02)',
|
||||
borderBottom: '1px solid rgba(0,0,0,0.04)'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={15} className="me-2" style={{ color: '#f59e0b' }} />
|
||||
<span className="fw-medium small">Туннель</span>
|
||||
</div>
|
||||
{expandedSections.tunnel ? <IconChevronDown size={14} /> : <IconChevronRight size={14} />}
|
||||
</div>
|
||||
{expandedSections.tunnel && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byTunnel)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tunnel, count]) => (
|
||||
<button
|
||||
key={tunnel}
|
||||
className={`btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.tunnel === tunnel ? 'btn-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleFilterClick('tunnel', tunnel)}
|
||||
style={{
|
||||
background: filters.tunnel === tunnel ? undefined : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 10px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (filters.tunnel !== tunnel) {
|
||||
e.currentTarget.style.background = 'rgba(0,0,0,0.04)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (filters.tunnel !== tunnel) {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={filters.tunnel === tunnel ? 'text-white' : ''}>{tunnel}</span>
|
||||
<span
|
||||
className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt text-secondary'}`}
|
||||
style={{ fontSize: '0.7rem', padding: '3px 7px' }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Секция: Страны */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('country')}
|
||||
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconWorld size={16} className="me-2 text-azure" />
|
||||
<span className="fw-medium small">Страны</span>
|
||||
</div>
|
||||
{expandedSections.country ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
{/* Статистика внизу */}
|
||||
<div
|
||||
className="px-3 py-3"
|
||||
style={{
|
||||
borderTop: '1px solid rgba(0,0,0,0.06)',
|
||||
background: 'rgba(0,0,0,0.01)'
|
||||
}}
|
||||
>
|
||||
<div className="d-flex align-items-center mb-2">
|
||||
<IconDatabase size={14} className="text-muted me-2" />
|
||||
<span className="text-muted small">Статистика</span>
|
||||
</div>
|
||||
{expandedSections.country && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byCountry)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([country, count]) => (
|
||||
<button
|
||||
key={country}
|
||||
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.country === country ? 'btn-primary' : 'btn-ghost-secondary'
|
||||
}`}
|
||||
onClick={() => handleFilterClick('country', country)}
|
||||
>
|
||||
<span>
|
||||
<span className="me-2">{countryToFlag(country)}</span>
|
||||
{country}
|
||||
</span>
|
||||
<span className={`badge ${filters.country === country ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Провайдеры */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('provider')}
|
||||
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconBuildingSkyscraper size={16} className="me-2 text-purple" />
|
||||
<span className="fw-medium small">Провайдеры</span>
|
||||
</div>
|
||||
{expandedSections.provider ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span className="small text-muted">Всего серверов</span>
|
||||
<span className="small fw-bold">{servers.length}</span>
|
||||
</div>
|
||||
{expandedSections.provider && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byProvider)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([provider, count]) => (
|
||||
<button
|
||||
key={provider}
|
||||
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.provider === provider ? 'btn-primary' : 'btn-ghost-secondary'
|
||||
}`}
|
||||
onClick={() => handleFilterClick('provider', provider)}
|
||||
>
|
||||
<span>{provider}</span>
|
||||
<span className={`badge ${filters.provider === provider ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Типы серверов */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('type')}
|
||||
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconServer size={16} className="me-2 text-green" />
|
||||
<span className="fw-medium small">Тип сервера</span>
|
||||
</div>
|
||||
{expandedSections.type ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span className="small text-muted">Выходных нод</span>
|
||||
<span className="small fw-bold" style={{ color: '#ef4444' }}>{stats.byType['exit'] || 0}</span>
|
||||
</div>
|
||||
{expandedSections.type && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byType)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type, count]) => (
|
||||
<button
|
||||
key={type}
|
||||
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.type === type ? 'btn-primary' : 'btn-ghost-secondary'
|
||||
}`}
|
||||
onClick={() => handleFilterClick('type', type)}
|
||||
>
|
||||
<span className="d-flex align-items-center">
|
||||
<span className={`status-dot me-2 ${type === 'exit' ? 'bg-danger' : 'bg-azure'}`}></span>
|
||||
{typeLabels[type] || type}
|
||||
</span>
|
||||
<span className={`badge ${filters.type === type ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Секция: Туннели */}
|
||||
<div className="filter-section">
|
||||
<div
|
||||
className="filter-section-header d-flex align-items-center justify-content-between px-3 py-2 cursor-pointer"
|
||||
onClick={() => toggleSection('tunnel')}
|
||||
style={{ background: 'var(--tblr-bg-surface-secondary)' }}
|
||||
>
|
||||
<div className="d-flex align-items-center">
|
||||
<IconNetwork size={16} className="me-2 text-orange" />
|
||||
<span className="fw-medium small">Тип туннеля</span>
|
||||
</div>
|
||||
{expandedSections.tunnel ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
</div>
|
||||
{expandedSections.tunnel && (
|
||||
<div className="p-2">
|
||||
{Object.entries(stats.byTunnel)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tunnel, count]) => (
|
||||
<button
|
||||
key={tunnel}
|
||||
className={`filter-item btn btn-sm w-100 text-start mb-1 d-flex align-items-center justify-content-between ${
|
||||
filters.tunnel === tunnel ? 'btn-primary' : 'btn-ghost-secondary'
|
||||
}`}
|
||||
onClick={() => handleFilterClick('tunnel', tunnel)}
|
||||
>
|
||||
<span>{tunnel}</span>
|
||||
<span className={`badge ${filters.tunnel === tunnel ? 'bg-white text-primary' : 'bg-secondary-lt'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="p-3 border-top mt-auto">
|
||||
<div className="text-muted small">
|
||||
<div className="d-flex justify-content-between mb-1">
|
||||
<span>Всего серверов:</span>
|
||||
<span className="fw-bold text-body">{servers.length}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between">
|
||||
<span>Выходных нод:</span>
|
||||
<span className="fw-bold text-danger">{stats.byType['exit'] || 0}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between">
|
||||
<span>Jumphosts:</span>
|
||||
<span className="fw-bold text-azure">{stats.byType['jumphost'] || 0}</span>
|
||||
</div>
|
||||
<div className="d-flex justify-content-between">
|
||||
<span className="small text-muted">Jumphosts</span>
|
||||
<span className="small fw-bold" style={{ color: '#3b82f6' }}>{stats.byType['jumphost'] || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user