feat(network): поиск, help, история центра и fullscreen PWA
This commit is contained in:
parent
2350745e61
commit
1e8e2915f9
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.13
|
client.version=1.2.14
|
||||||
server.version=1.2.13
|
server.version=1.2.14
|
||||||
|
|||||||
@ -32,6 +32,15 @@ function uniqueLogins(list) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user