258 lines
9.0 KiB
JavaScript
258 lines
9.0 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react';
|
||
import { api } from '../api.js';
|
||
import {
|
||
autoServer,
|
||
filterServers,
|
||
groupServers,
|
||
SERVER_RESULT_WINDOW,
|
||
} from '../utils/serverPicker.js';
|
||
|
||
const FAVORITES_KEY = 'harbor-server-favorites';
|
||
const RECENT_KEY = 'harbor-server-recent';
|
||
const AUTO_KEY = 'harbor-server-auto';
|
||
|
||
function readList(key) {
|
||
try {
|
||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||
return Array.isArray(value) ? value.map(String) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function write(key, value) {
|
||
try {
|
||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||
} catch {
|
||
// Preferences remain available for this session.
|
||
}
|
||
}
|
||
|
||
function readAuto() {
|
||
try {
|
||
return localStorage.getItem(AUTO_KEY) === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function ServerRow({ server, selected, favorite, ping, disabled, index, onSelect, onFavorite }) {
|
||
const health = ping?.checking
|
||
? 'Проверяем TCP…'
|
||
: ping?.error ? 'Проверка недоступна'
|
||
: ping?.ok ? `TCP ${ping.latency} мс` : ping ? 'TCP недоступен' : 'Не проверен';
|
||
|
||
return <div className={`client-server-row${selected ? ' is-selected' : ''}${onFavorite ? ' has-favorite' : ''}`}>
|
||
<button
|
||
className={`client-server${selected ? ' is-selected' : ''}`}
|
||
type="button"
|
||
disabled={disabled}
|
||
aria-pressed={selected}
|
||
aria-label={`${server.label}, ${server.host}:${server.port}, ${health}`}
|
||
style={{ '--server-index': Math.min(index, 7) }}
|
||
onClick={() => onSelect(server.id)}
|
||
>
|
||
<strong>{server.label}</strong>
|
||
<small title={ping?.checkedAt || undefined}>{health}</small>
|
||
</button>
|
||
{onFavorite && <button
|
||
className={`client-server-favorite${favorite ? ' is-active' : ''}`}
|
||
type="button"
|
||
aria-pressed={favorite}
|
||
aria-label={`${favorite ? 'Убрать из избранного' : 'Добавить в избранное'}: ${server.label}`}
|
||
onClick={() => onFavorite(server.id)}
|
||
>★</button>}
|
||
</div>;
|
||
}
|
||
|
||
export function ServerPicker({
|
||
servers,
|
||
selectedServerId,
|
||
disabled,
|
||
prompt,
|
||
leaving,
|
||
revealVersion,
|
||
onSelect,
|
||
}) {
|
||
const [query, setQuery] = useState('');
|
||
const [view, setView] = useState('all');
|
||
const [page, setPage] = useState(0);
|
||
const [favorites, setFavorites] = useState(() => readList(FAVORITES_KEY));
|
||
const [recent, setRecent] = useState(() => readList(RECENT_KEY));
|
||
const [autoActive, setAutoActive] = useState(readAuto);
|
||
const [collapsed, setCollapsed] = useState([]);
|
||
const [pings, setPings] = useState({});
|
||
const [checking, setChecking] = useState(false);
|
||
const serverKey = servers.map(({ id }) => id).join('|');
|
||
|
||
useEffect(() => {
|
||
setPage(0);
|
||
}, [query, view, serverKey]);
|
||
|
||
const selected = servers.find(({ id }) => id === selectedServerId);
|
||
const filtered = useMemo(() => {
|
||
const found = filterServers(servers, query);
|
||
if (view === 'favorites') return found.filter(({ id }) => favorites.includes(id));
|
||
if (view === 'recent') return recent.flatMap((id) => found.find((server) => server.id === id) || []);
|
||
return found;
|
||
}, [servers, query, view, favorites, recent]);
|
||
const results = filtered.filter(({ id }) => id !== selectedServerId);
|
||
const pageCount = Math.max(1, Math.ceil(results.length / SERVER_RESULT_WINDOW));
|
||
const visible = results.slice(page * SERVER_RESULT_WINDOW, (page + 1) * SERVER_RESULT_WINDOW);
|
||
const grouped = servers.length >= 10;
|
||
|
||
useEffect(() => {
|
||
setPage((current) => Math.min(current, pageCount - 1));
|
||
}, [pageCount]);
|
||
|
||
function toggleFavorite(id) {
|
||
setFavorites((current) => {
|
||
const next = current.includes(id) ? current.filter((item) => item !== id) : [id, ...current];
|
||
write(FAVORITES_KEY, next);
|
||
return next;
|
||
});
|
||
}
|
||
|
||
function select(id, automatic = false) {
|
||
setAutoActive(automatic);
|
||
write(AUTO_KEY, String(automatic));
|
||
if (!automatic) {
|
||
setRecent((current) => {
|
||
const next = [id, ...current.filter((item) => item !== id)].slice(0, 5);
|
||
write(RECENT_KEY, next);
|
||
return next;
|
||
});
|
||
}
|
||
onSelect(id);
|
||
}
|
||
|
||
async function checkVisible() {
|
||
const ids = [...new Set([selectedServerId, ...visible.map(({ id }) => id)].filter(Boolean))].slice(0, 30);
|
||
if (!ids.length) return;
|
||
setChecking(true);
|
||
setPings((current) => ({
|
||
...current,
|
||
...Object.fromEntries(ids.map((id) => [id, { checking: true }])),
|
||
}));
|
||
try {
|
||
const data = await api.servers.ping(ids);
|
||
setPings((current) => ({
|
||
...current,
|
||
...Object.fromEntries((data.results || []).map((result) => [result.id, result])),
|
||
}));
|
||
} catch {
|
||
setPings((current) => ({
|
||
...current,
|
||
...Object.fromEntries(ids.map((id) => [id, { error: true, checkedAt: new Date().toISOString() }])),
|
||
}));
|
||
} finally {
|
||
setChecking(false);
|
||
}
|
||
}
|
||
|
||
if (servers.length === 1) {
|
||
return <section className="client-servers" aria-label="Выберите сервер">
|
||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||
<div className="client-server-grid">
|
||
<ServerRow
|
||
server={servers[0]}
|
||
selected={servers[0].id === selectedServerId}
|
||
favorite={false}
|
||
disabled={disabled}
|
||
index={0}
|
||
onSelect={onSelect}
|
||
/>
|
||
</div>
|
||
</section>;
|
||
}
|
||
|
||
const renderRows = (items, offset = 0) => items.map((server, index) => (
|
||
<ServerRow
|
||
key={server.id}
|
||
server={server}
|
||
selected={!autoActive && server.id === selectedServerId}
|
||
favorite={favorites.includes(server.id)}
|
||
ping={pings[server.id]}
|
||
disabled={disabled}
|
||
index={offset + index}
|
||
onSelect={select}
|
||
onFavorite={toggleFavorite}
|
||
/>
|
||
));
|
||
|
||
return <section className="client-servers is-scalable" aria-label="Выберите сервер">
|
||
{prompt && <span className="client-server-prompt">Выберите сервер</span>}
|
||
<div className="client-server-tools">
|
||
<input
|
||
type="search"
|
||
value={query}
|
||
aria-label="Найти сервер"
|
||
placeholder="Поиск сервера"
|
||
onChange={(event) => setQuery(event.target.value)}
|
||
/>
|
||
<div className="client-server-filters" aria-label="Фильтр серверов">
|
||
{[
|
||
['all', 'Все'],
|
||
['favorites', '★'],
|
||
['recent', 'Недавние'],
|
||
].map(([id, label]) => <button
|
||
type="button"
|
||
className={view === id ? 'is-active' : ''}
|
||
aria-pressed={view === id}
|
||
key={id}
|
||
onClick={() => setView(id)}
|
||
>{label}</button>)}
|
||
<button type="button" disabled={checking || !visible.length} onClick={checkVisible}>
|
||
{checking ? 'Проверяем…' : 'Проверить TCP'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="client-server-pinned">
|
||
<button
|
||
className={`client-server-auto${autoActive ? ' is-selected' : ''}`}
|
||
type="button"
|
||
aria-pressed={autoActive}
|
||
disabled={disabled || !servers.length}
|
||
onClick={() => select(autoServer(servers)?.id, true)}
|
||
>
|
||
<strong>Auto</strong>
|
||
<small>Первый стабильный сервер</small>
|
||
</button>
|
||
{selected && !autoActive && <ServerRow
|
||
server={selected}
|
||
selected
|
||
favorite={favorites.includes(selected.id)}
|
||
ping={pings[selected.id]}
|
||
disabled={disabled}
|
||
index={0}
|
||
onSelect={select}
|
||
onFavorite={toggleFavorite}
|
||
/>}
|
||
</div>
|
||
|
||
<div className={`client-server-scroll${leaving ? ' is-leaving' : ''}`} key={`${serverKey}:${revealVersion}`}>
|
||
{!visible.length && <p className="client-server-empty">Серверы не найдены</p>}
|
||
{grouped ? groupServers(visible).map(([group, items]) => {
|
||
const isCollapsed = collapsed.includes(group);
|
||
return <section className="client-server-group" key={group}>
|
||
<button
|
||
className="client-server-group-toggle"
|
||
type="button"
|
||
aria-expanded={!isCollapsed}
|
||
onClick={() => setCollapsed((current) => current.includes(group)
|
||
? current.filter((item) => item !== group)
|
||
: [...current, group])}
|
||
>{group} <small>{items.length}</small></button>
|
||
{!isCollapsed && <div className="client-server-grid">{renderRows(items)}</div>}
|
||
</section>;
|
||
}) : <div className="client-server-grid">{renderRows(visible)}</div>}
|
||
{pageCount > 1 && <nav className="client-server-pages" aria-label="Страницы серверов">
|
||
<button className="client-server-more" type="button" disabled={page === 0} onClick={() => setPage((current) => current - 1)}>Назад</button>
|
||
<span>{page + 1} / {pageCount}</span>
|
||
<button className="client-server-more" type="button" disabled={page + 1 === pageCount} onClick={() => setPage((current) => current + 1)}>Дальше</button>
|
||
</nav>}
|
||
</div>
|
||
</section>;
|
||
}
|