feat(network): поиск, help, история центра и fullscreen PWA

This commit is contained in:
AidarKC 2026-04-26 18:50:36 +03:00
parent 2350745e61
commit 1e8e2915f9
5 changed files with 265 additions and 31 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.13 client.version=1.2.14
server.version=1.2.13 server.version=1.2.14

View File

@ -32,6 +32,15 @@ function uniqueLogins(list) {
return out; return out;
} }
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function normalizeGender(value) { function normalizeGender(value) {
const clean = String(value || '').trim().toLowerCase(); const clean = String(value || '').trim().toLowerCase();
if (clean === GENDER_MALE) return GENDER_MALE; if (clean === GENDER_MALE) return GENDER_MALE;
@ -493,28 +502,25 @@ function renderEdges(svg, board, nodeElements, edges) {
}); });
} }
export function render({ navigate }) { let persistedCenterLogin = '';
let persistedCenterHistory = [];
export function render({ navigate, route }) {
const keepHistory = String(route?.params?.mode || '').trim().toLowerCase() === 'keep-history';
if (!keepHistory) {
persistedCenterLogin = '';
persistedCenterHistory = [];
}
const screen = document.createElement('section'); const screen = document.createElement('section');
screen.className = 'stack'; screen.className = 'stack network-screen';
const board = document.createElement('div'); const board = document.createElement('div');
board.className = 'network-board'; board.className = 'network-board network-board--full';
board.style.height = 'calc(100dvh - 170px)';
const note = document.createElement('p');
note.className = 'meta-muted';
note.textContent = 'Загрузка связей...';
const legend = document.createElement('div');
legend.className = 'network-legend';
legend.innerHTML = `
<span><i class="legend-line friend"></i> Близкие друзья</span>
<span><i class="legend-line relative"></i> Родственники</span>
<span><i class="legend-arrow"></i> Односторонняя связь</span>
`;
const profileCardCache = new Map(); const profileCardCache = new Map();
let centerLogin = state.session.login || ''; let centerLogin = normalizeLogin(persistedCenterLogin || state.session.login || '');
let centerHistory = Array.isArray(persistedCenterHistory) ? [...persistedCenterHistory] : [];
let redrawEdges = () => {}; let redrawEdges = () => {};
let loadSeq = 0; let loadSeq = 0;
@ -522,7 +528,165 @@ export function render({ navigate }) {
const cleanLogin = normalizeLogin(login); const cleanLogin = normalizeLogin(login);
if (!cleanLogin) return ''; if (!cleanLogin) return '';
if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view'; if (normKey(cleanLogin) === normKey(state.session.login)) return 'profile-view';
return `user-profile-view/${encodeURIComponent(cleanLogin)}/network-view`; return `user-profile-view/${encodeURIComponent(cleanLogin)}/${encodeURIComponent('network-view/keep-history')}`;
}
function helpText() {
return [
'Обозначения на экране связей:',
'• Синие линии — близкие друзья.',
'• Оранжевые линии — родственники.',
'• Стрелка на линии — односторонняя связь.',
'• Если стрелки нет, связь взаимная.',
].join('\n');
}
function persistHistory() {
persistedCenterLogin = centerLogin;
persistedCenterHistory = [...centerHistory];
}
function setBackButtonState(backBtn) {
if (!(backBtn instanceof HTMLButtonElement)) return;
backBtn.disabled = centerHistory.length === 0;
}
function openSearchModal() {
const root = document.getElementById('modal-root');
if (!(root instanceof HTMLElement)) return;
root.innerHTML = `
<div class="modal" id="network-search-modal">
<div class="modal-card stack">
<button class="icon-btn" type="button" id="network-search-close" style="justify-self:end;"></button>
<h3 class="modal-title">Найти человека</h3>
<div class="row" style="gap:8px;">
<input class="input" id="network-search-input" type="text" maxlength="30" placeholder="Введите логин" />
<button class="primary-btn" type="button" id="network-search-run">Искать</button>
</div>
<div class="meta-muted" id="network-search-meta">Введите логин и нажмите «Искать».</div>
<div class="stack" id="network-search-results"></div>
<div class="form-actions-grid">
<button class="ghost-btn" type="button" id="network-search-profile" disabled>Показать профиль</button>
<button class="primary-btn" type="button" id="network-search-graph" disabled>Показать связи</button>
</div>
<button class="secondary-btn" type="button" id="network-search-ok" disabled>OK</button>
</div>
</div>
`;
const modal = root.querySelector('#network-search-modal');
const closeBtn = root.querySelector('#network-search-close');
const inputEl = root.querySelector('#network-search-input');
const runBtn = root.querySelector('#network-search-run');
const metaEl = root.querySelector('#network-search-meta');
const resultsEl = root.querySelector('#network-search-results');
const profileBtn = root.querySelector('#network-search-profile');
const graphBtn = root.querySelector('#network-search-graph');
const okBtn = root.querySelector('#network-search-ok');
if (!(modal instanceof HTMLElement) || !(inputEl instanceof HTMLInputElement) || !(resultsEl instanceof HTMLElement)) {
root.innerHTML = '';
return;
}
let selectedLogin = '';
let searchSeq = 0;
const close = () => {
root.innerHTML = '';
};
const applySelection = (login) => {
selectedLogin = normalizeLogin(login);
const rows = resultsEl.querySelectorAll('[data-candidate]');
rows.forEach((row) => {
if (!(row instanceof HTMLElement)) return;
row.classList.toggle('is-selected', String(row.dataset.candidate || '') === selectedLogin);
});
const hasSelected = Boolean(selectedLogin);
if (profileBtn instanceof HTMLButtonElement) profileBtn.disabled = !hasSelected;
if (graphBtn instanceof HTMLButtonElement) graphBtn.disabled = !hasSelected;
if (okBtn instanceof HTMLButtonElement) okBtn.disabled = !hasSelected;
};
const renderCandidates = (logins) => {
const items = (Array.isArray(logins) ? logins : [])
.map((item) => normalizeLogin(item))
.filter(Boolean)
.slice(0, 5);
if (!items.length) {
resultsEl.innerHTML = '<div class="meta-muted">Кандидаты не найдены.</div>';
applySelection('');
return;
}
resultsEl.innerHTML = items.map((login) => (
`<button type="button" class="ghost-btn network-search-candidate" data-candidate="${escapeHtml(login)}">${escapeHtml(login)}</button>`
)).join('');
applySelection('');
};
const runSearch = async () => {
const query = normalizeLogin(inputEl.value);
if (!query) {
metaEl.textContent = 'Введите логин.';
renderCandidates([]);
return;
}
const reqId = ++searchSeq;
metaEl.textContent = `Поиск по «${query}»...`;
if (runBtn instanceof HTMLButtonElement) runBtn.disabled = true;
try {
const found = await authService.searchUsers(query);
if (reqId !== searchSeq) return;
renderCandidates(found);
const foundCount = Math.min(5, Array.isArray(found) ? found.length : 0);
metaEl.textContent = foundCount > 0
? `Найдено кандидатов: ${foundCount}. Выберите одного.`
: 'Кандидаты не найдены.';
} catch (error) {
if (reqId !== searchSeq) return;
renderCandidates([]);
metaEl.textContent = `Ошибка поиска: ${error?.message || 'unknown'}`;
} finally {
if (runBtn instanceof HTMLButtonElement) runBtn.disabled = false;
}
};
modal.addEventListener('click', (event) => {
if (event.target === modal) close();
});
closeBtn?.addEventListener('click', close);
runBtn?.addEventListener('click', () => { void runSearch(); });
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
void runSearch();
}
});
resultsEl.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest('[data-candidate]');
if (!(button instanceof HTMLElement)) return;
applySelection(String(button.dataset.candidate || ''));
});
profileBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
const routeTo = profileInfoRoute(selectedLogin);
if (!routeTo) return;
close();
navigate(routeTo);
});
graphBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
close();
void load(selectedLogin, { pushHistory: true });
});
okBtn?.addEventListener('click', () => {
if (!selectedLogin) return;
metaEl.textContent = `Выбран пользователь «${selectedLogin}». Используйте кнопки ниже.`;
});
window.setTimeout(() => inputEl.focus(), 0);
} }
function getProfileCardCached(login) { function getProfileCardCached(login) {
@ -580,19 +744,22 @@ export function render({ navigate }) {
if (routeTo) navigate(routeTo); if (routeTo) navigate(routeTo);
return; return;
} }
void load(nodeModel.login); void load(nodeModel.login, { pushHistory: true });
}); });
} }
async function load(nextCenterLogin = '') { async function load(nextCenterLogin = '', { pushHistory = false } = {}) {
const requestId = ++loadSeq; const requestId = ++loadSeq;
const targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login); const prevCenter = centerLogin;
centerLogin = targetCenter; const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
note.textContent = 'Загрузка связей...';
try { try {
const graph = await authService.getUserConnectionsGraph(targetCenter); const graph = await authService.getUserConnectionsGraph(targetCenter);
if (requestId !== loadSeq) return; if (requestId !== loadSeq) return;
centerLogin = targetCenter;
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
centerHistory.push(prevCenter);
}
const model = buildGraphModel(graph, targetCenter); const model = buildGraphModel(graph, targetCenter);
const layout = layoutNodes(model); const layout = layoutNodes(model);
@ -622,14 +789,36 @@ export function render({ navigate }) {
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges); redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
requestAnimationFrame(() => redrawEdges()); requestAnimationFrame(() => redrawEdges());
void hydrateNodeProfiles(layout, nodeElements, requestId); void hydrateNodeProfiles(layout, nodeElements, requestId);
persistHistory();
note.textContent = 'Тап по узлу: нецентральный узел станет центром. Тап по центральному узлу: открыть профиль.'; setBackButtonState(backBtnEl);
} catch (error) { } catch (error) {
if (requestId !== loadSeq) return; if (requestId !== loadSeq) return;
note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`; window.alert(`Ошибка загрузки связей: ${error?.message || 'unknown'}`);
} }
} }
const header = renderHeader({
title: 'Связи',
leftAction: {
label: '←',
onClick: () => {
if (!centerHistory.length) return;
const prev = centerHistory.pop();
if (!prev) {
setBackButtonState(backBtnEl);
return;
}
void load(prev, { pushHistory: false });
},
},
rightActions: [
{ label: 'Найти человека', onClick: openSearchModal },
{ label: '?', onClick: () => window.alert(helpText()) },
],
});
const backBtnEl = header.querySelector('.header-left .icon-btn');
setBackButtonState(backBtnEl);
const onResize = () => redrawEdges(); const onResize = () => redrawEdges();
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
@ -644,7 +833,16 @@ export function render({ navigate }) {
if (observer) observer.disconnect(); if (observer) observer.disconnect();
}; };
load(); if (keepHistory && centerLogin) {
screen.append(renderHeader({ title: 'Связи' }), legend, board, note); void load(centerLogin, { pushHistory: false });
} else {
centerLogin = normalizeLogin(state.session.login || '');
centerHistory = [];
persistHistory();
void load(centerLogin, { pushHistory: false });
}
setBackButtonState(backBtnEl);
screen.append(header, board);
return screen; return screen;
} }

View File

@ -78,6 +78,15 @@ export function getRoute() {
}; };
} }
if (pageId === 'network-view') {
return {
pageId,
params: {
mode: segments[1] ? decodePart(segments[1]) : '',
},
};
}
return { pageId, params: {} }; return { pageId, params: {} };
} }

View File

@ -2,7 +2,13 @@
"name": "Shine UI", "name": "Shine UI",
"short_name": "Shine", "short_name": "Shine",
"start_url": "./index.html", "start_url": "./index.html",
"display": "standalone", "display": "fullscreen",
"display_override": [
"fullscreen",
"standalone",
"minimal-ui"
],
"orientation": "any",
"background_color": "#0b1020", "background_color": "#0b1020",
"theme_color": "#0b1020", "theme_color": "#0b1020",
"icons": [ "icons": [

View File

@ -1327,6 +1327,17 @@ textarea.input {
overflow: hidden; overflow: hidden;
} }
.network-screen {
gap: 8px;
min-height: calc(100dvh - 124px);
}
.network-board--full {
flex: 1 1 auto;
min-height: calc(100dvh - 212px);
height: auto;
}
.network-legend { .network-legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -1509,6 +1520,16 @@ textarea.input {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.network-search-candidate {
width: 100%;
justify-content: flex-start;
}
.network-search-candidate.is-selected {
border-color: rgba(132, 209, 255, 0.78);
background: rgba(65, 118, 191, 0.24);
}
.node:focus-visible .node-dot, .node:focus-visible .node-dot,
.node:hover .node-dot { .node:hover .node-dot {
border-color: rgba(166, 218, 255, 0.92); border-color: rgba(166, 218, 255, 0.92);