From 1e8e2915f98c79c18fcda4271e27e1a4cbbf5d1d76bbae093a5453ff85204f89 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Sun, 26 Apr 2026 18:50:36 +0300 Subject: [PATCH] =?UTF-8?q?feat(network):=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA?= =?UTF-8?q?,=20help,=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D1=80=D0=B0=20=D0=B8=20fullscreen=20PWA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION.properties | 4 +- shine-UI/js/pages/network-view.js | 254 ++++++++++++++++++++++++++---- shine-UI/js/router.js | 9 ++ shine-UI/manifest.webmanifest | 8 +- shine-UI/styles/components.css | 21 +++ 5 files changed, 265 insertions(+), 31 deletions(-) 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);