feat(network): поиск, help, история центра и fullscreen PWA
This commit is contained in:
parent
2350745e61
commit
1e8e2915f9
@ -1,2 +1,2 @@
|
||||
client.version=1.2.13
|
||||
server.version=1.2.13
|
||||
client.version=1.2.14
|
||||
server.version=1.2.14
|
||||
|
||||
@ -32,6 +32,15 @@ function uniqueLogins(list) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function normalizeGender(value) {
|
||||
const clean = String(value || '').trim().toLowerCase();
|
||||
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');
|
||||
screen.className = 'stack';
|
||||
screen.className = 'stack network-screen';
|
||||
|
||||
const board = document.createElement('div');
|
||||
board.className = 'network-board';
|
||||
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>
|
||||
`;
|
||||
board.className = 'network-board network-board--full';
|
||||
|
||||
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 loadSeq = 0;
|
||||
|
||||
@ -522,7 +528,165 @@ export function render({ navigate }) {
|
||||
const cleanLogin = normalizeLogin(login);
|
||||
if (!cleanLogin) return '';
|
||||
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) {
|
||||
@ -580,19 +744,22 @@ export function render({ navigate }) {
|
||||
if (routeTo) navigate(routeTo);
|
||||
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 targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login);
|
||||
centerLogin = targetCenter;
|
||||
note.textContent = 'Загрузка связей...';
|
||||
const prevCenter = centerLogin;
|
||||
const targetCenter = normalizeLogin(nextCenterLogin || prevCenter || state.session.login);
|
||||
|
||||
try {
|
||||
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
||||
if (requestId !== loadSeq) return;
|
||||
centerLogin = targetCenter;
|
||||
if (pushHistory && prevCenter && normKey(prevCenter) !== normKey(targetCenter)) {
|
||||
centerHistory.push(prevCenter);
|
||||
}
|
||||
|
||||
const model = buildGraphModel(graph, targetCenter);
|
||||
const layout = layoutNodes(model);
|
||||
@ -622,14 +789,36 @@ export function render({ navigate }) {
|
||||
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
|
||||
requestAnimationFrame(() => redrawEdges());
|
||||
void hydrateNodeProfiles(layout, nodeElements, requestId);
|
||||
|
||||
note.textContent = 'Тап по узлу: нецентральный узел станет центром. Тап по центральному узлу: открыть профиль.';
|
||||
persistHistory();
|
||||
setBackButtonState(backBtnEl);
|
||||
} catch (error) {
|
||||
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();
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
@ -644,7 +833,16 @@ export function render({ navigate }) {
|
||||
if (observer) observer.disconnect();
|
||||
};
|
||||
|
||||
load();
|
||||
screen.append(renderHeader({ title: 'Связи' }), legend, board, note);
|
||||
if (keepHistory && centerLogin) {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -78,6 +78,15 @@ export function getRoute() {
|
||||
};
|
||||
}
|
||||
|
||||
if (pageId === 'network-view') {
|
||||
return {
|
||||
pageId,
|
||||
params: {
|
||||
mode: segments[1] ? decodePart(segments[1]) : '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { pageId, params: {} };
|
||||
}
|
||||
|
||||
|
||||
@ -2,7 +2,13 @@
|
||||
"name": "Shine UI",
|
||||
"short_name": "Shine",
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"display": "fullscreen",
|
||||
"display_override": [
|
||||
"fullscreen",
|
||||
"standalone",
|
||||
"minimal-ui"
|
||||
],
|
||||
"orientation": "any",
|
||||
"background_color": "#0b1020",
|
||||
"theme_color": "#0b1020",
|
||||
"icons": [
|
||||
|
||||
@ -1327,6 +1327,17 @@ textarea.input {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -1509,6 +1520,16 @@ textarea.input {
|
||||
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:hover .node-dot {
|
||||
border-color: rgba(166, 218, 255, 0.92);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user