diff --git a/VERSION.properties b/VERSION.properties
index 731480f..9d5f554 100644
--- a/VERSION.properties
+++ b/VERSION.properties
@@ -1,2 +1,2 @@
-client.version=1.2.13
-server.version=1.2.13
+client.version=1.2.14
+server.version=1.2.14
diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js
index f598645..2083586 100644
--- a/shine-UI/js/pages/network-view.js
+++ b/shine-UI/js/pages/network-view.js
@@ -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 = `
- Близкие друзья
- Родственники
- Односторонняя связь
- `;
+ 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 = `
+
+
+
+
Найти человека
+
+
+
+
+
Введите логин и нажмите «Искать».
+
+
+
+
+
+
+
+
+ `;
+
+ 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 = 'Кандидаты не найдены.
';
+ applySelection('');
+ return;
+ }
+ resultsEl.innerHTML = items.map((login) => (
+ ``
+ )).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;
}
diff --git a/shine-UI/js/router.js b/shine-UI/js/router.js
index 6d4dcaa..c43760f 100644
--- a/shine-UI/js/router.js
+++ b/shine-UI/js/router.js
@@ -78,6 +78,15 @@ export function getRoute() {
};
}
+ if (pageId === 'network-view') {
+ return {
+ pageId,
+ params: {
+ mode: segments[1] ? decodePart(segments[1]) : '',
+ },
+ };
+ }
+
return { pageId, params: {} };
}
diff --git a/shine-UI/manifest.webmanifest b/shine-UI/manifest.webmanifest
index 71976c6..7689fdc 100644
--- a/shine-UI/manifest.webmanifest
+++ b/shine-UI/manifest.webmanifest
@@ -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": [
diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css
index ba4649c..b2a6222 100644
--- a/shine-UI/styles/components.css
+++ b/shine-UI/styles/components.css
@@ -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);