From 4a92a7fa22a01425dece00de11e8c511c0878fcdf11f86e388ff69f94cd13107 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Fri, 17 Apr 2026 21:01:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=80=D0=BE=D0=B4=D1=81=D1=82=D0=B2=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B8,=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=20=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=20=D1=81=D0=B2=D1=8F=D0=B7=D0=B5=D0=B9=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=20=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что добавлено:\n- Новые типы CONNECTION для родственников: parent/child/sibling (50/51, 52/53, 54/55) в blockchain/db слоях.\n- Обновлены проверки ConnectionBody и DB-триггер connections_state для корректной записи/удаления новых связей.\n- В профиле добавлен блок "Близкие родственники" с модальным выбором типа связи и логина; добавление через AddBlock для parent/child/sibling.\n- Расширен API GetUserConnectionsGraph: out/in списки для родителей/детей/сиблингов, агрегированные списки родственников с полом, список allUsers с метками официальный/сияющий.\n- Полностью обновлен UI страницы "Связи": новое позиционирование родственников вокруг центра, отдельный цвет родственных связей, линия для взаимных связей и стрелка для односторонних, корректная геометрия линий при ресайзе.\n- Добавлена Gradle-задача startLocalWithBuild для запуска локального стека после build; сохранена отдельная startLocal без полного build. --- build.gradle | 12 + shine-UI/js/pages/network-view.js | 453 ++++++++++++++++-- shine-UI/js/pages/profile-view.js | 229 ++++++++- shine-UI/js/services/auth-service.js | 3 + shine-UI/js/services/user-connections.js | 24 + shine-UI/styles/components.css | 85 +++- .../src/main/java/blockchain/MsgSubType.java | 15 + .../java/blockchain/body/ConnectionBody.java | 13 +- .../java/shine/db/DatabaseInitializer.java | 9 + .../shine/db/DatabaseTriggersInstaller.java | 29 +- .../src/main/java/shine/db/MsgSubType.java | 22 +- .../Net_GetUserConnectionsGraph_Handler.java | 178 +++++++ .../Net_GetUserConnectionsGraph_Response.java | 62 +++ 13 files changed, 1061 insertions(+), 73 deletions(-) diff --git a/build.gradle b/build.gradle index 72c5e0a..2333809 100644 --- a/build.gradle +++ b/build.gradle @@ -271,3 +271,15 @@ tasks.register('startLocal', Exec) { fi """ } + +tasks.register('startLocalWithBuild') { + group = "!!run" + description = "Build server (build) and then start local stack (startLocal)" + + dependsOn tasks.named('build') + dependsOn tasks.named('startLocal') +} + +tasks.named('startLocal').configure { + mustRunAfter tasks.named('build') +} diff --git a/shine-UI/js/pages/network-view.js b/shine-UI/js/pages/network-view.js index 4ace200..b831b71 100644 --- a/shine-UI/js/pages/network-view.js +++ b/shine-UI/js/pages/network-view.js @@ -3,16 +3,352 @@ import { authService, state } from '../state.js'; export const pageMeta = { id: 'network-view', title: 'Связи' }; -function makeNode(name, cls = '') { - const n = document.createElement('div'); - n.className = `node ${cls}`.trim(); - n.dataset.nodeLogin = name; - n.innerHTML = `
${(name[0] || '?').toUpperCase()}
${name}
`; - return n; +const GENDER_MALE = 'male'; +const GENDER_FEMALE = 'female'; +const GENDER_UNKNOWN = 'unknown'; +const CENTER_NODE_ID = '__center__'; + +function normalizeLogin(value) { + return String(value || '').trim(); } -function unique(list) { - return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))]; +function normKey(value) { + return normalizeLogin(value).toLowerCase(); +} + +function uniqueLogins(list) { + const out = []; + const seen = new Set(); + (Array.isArray(list) ? list : []).forEach((item) => { + const login = normalizeLogin(item); + if (!login) return; + const key = normKey(login); + if (seen.has(key)) return; + seen.add(key); + out.push(login); + }); + return out; +} + +function normalizeGender(value) { + const clean = String(value || '').trim().toLowerCase(); + if (clean === GENDER_MALE) return GENDER_MALE; + if (clean === GENDER_FEMALE) return GENDER_FEMALE; + return GENDER_UNKNOWN; +} + +function toSet(list) { + return new Set(uniqueLogins(list).map((value) => normKey(value))); +} + +function hasLogin(setObj, login) { + return setObj.has(normKey(login)); +} + +function getMarkByLogin(allUsers) { + const map = new Map(); + (Array.isArray(allUsers) ? allUsers : []).forEach((row) => { + const login = normalizeLogin(row?.login); + if (!login) return; + map.set(normKey(login), { + login, + official: Boolean(row?.official), + shine: Boolean(row?.shine), + officialLabel: String(row?.officialLabel || (row?.official ? 'официальный' : 'неофициальный')), + shineLabel: String(row?.shineLabel || (row?.shine ? 'сияющий' : 'несияющий')), + }); + }); + return map; +} + +function applyRelativeGender(map, rows) { + (Array.isArray(rows) ? rows : []).forEach((row) => { + const login = normalizeLogin(row?.login); + if (!login) return; + const key = normKey(login); + const gender = normalizeGender(row?.gender); + const prev = map.get(key) || GENDER_UNKNOWN; + if (prev === GENDER_UNKNOWN || gender !== GENDER_UNKNOWN) map.set(key, gender); + }); +} + +function getRelativeGenderMap(graph) { + const map = new Map(); + applyRelativeGender(map, graph?.parents); + applyRelativeGender(map, graph?.children); + applyRelativeGender(map, graph?.siblings); + return map; +} + +function spread(count, start, end) { + if (count <= 0) return []; + if (count === 1) return [(start + end) / 2]; + const out = []; + const step = (end - start) / (count - 1); + for (let i = 0; i < count; i += 1) out.push(start + step * i); + return out; +} + +function buildNodeElement({ login, kind = 'friend', isCenter = false, mark = null }) { + const node = document.createElement('button'); + node.type = 'button'; + node.className = `node ${isCenter ? 'center' : ''} ${kind === 'relative' ? 'is-relative' : 'is-friend'}`.trim(); + node.dataset.nodeLogin = login; + + const metaParts = []; + if (mark?.officialLabel) metaParts.push(mark.officialLabel); + if (mark?.shineLabel) metaParts.push(mark.shineLabel); + const metaText = metaParts.join(', '); + node.title = metaText ? `${login}\n${metaText}` : login; + + node.innerHTML = ` + ${(login[0] || '?').toUpperCase()} + ${login} + `; + return node; +} + +function buildGraphModel(graph, centerLogin) { + const login = normalizeLogin(graph?.login || centerLogin || state.session.login); + const outFriends = toSet(graph?.outFriends); + const inFriends = toSet(graph?.inFriends); + const outParents = toSet(graph?.outParents); + const inParents = toSet(graph?.inParents); + const outChildren = toSet(graph?.outChildren); + const inChildren = toSet(graph?.inChildren); + const outSiblings = toSet(graph?.outSiblings); + const inSiblings = toSet(graph?.inSiblings); + + const relativesGender = getRelativeGenderMap(graph); + const allMarks = getMarkByLogin(graph?.allUsers); + + const allLogins = uniqueLogins([ + ...(graph?.outFriends || []), + ...(graph?.inFriends || []), + ...(graph?.outParents || []), + ...(graph?.inParents || []), + ...(graph?.outChildren || []), + ...(graph?.inChildren || []), + ...(graph?.outSiblings || []), + ...(graph?.inSiblings || []), + ]).filter((entry) => normKey(entry) !== normKey(login)); + + const relations = allLogins.map((targetLogin) => { + const parentOut = hasLogin(outParents, targetLogin); + const parentIn = hasLogin(inChildren, targetLogin); + const childOut = hasLogin(outChildren, targetLogin); + const childIn = hasLogin(inParents, targetLogin); + const siblingOut = hasLogin(outSiblings, targetLogin); + const siblingIn = hasLogin(inSiblings, targetLogin); + const friendOut = hasLogin(outFriends, targetLogin); + const friendIn = hasLogin(inFriends, targetLogin); + + let role = 'friend'; + if (parentOut || parentIn) role = 'parent'; + else if (childOut || childIn) role = 'child'; + else if (siblingOut || siblingIn) role = 'sibling'; + + let forward = friendOut; + let backward = friendIn; + if (role === 'parent') { + forward = parentOut; + backward = parentIn; + } else if (role === 'child') { + forward = childOut; + backward = childIn; + } else if (role === 'sibling') { + forward = siblingOut; + backward = siblingIn; + } + + return { + login: targetLogin, + key: normKey(targetLogin), + role, + isRelative: role !== 'friend', + gender: normalizeGender(relativesGender.get(normKey(targetLogin))), + forward: Boolean(forward), + backward: Boolean(backward), + mark: allMarks.get(normKey(targetLogin)) || null, + }; + }); + + return { + centerLogin: login, + centerMark: allMarks.get(normKey(login)) || null, + relations, + }; +} + +function splitByGender(list) { + const left = []; + const right = []; + const center = []; + list.forEach((item) => { + if (item.gender === GENDER_FEMALE) left.push(item); + else if (item.gender === GENDER_MALE) right.push(item); + else center.push(item); + }); + return { left, right, center }; +} + +function sortByLogin(items) { + return [...items].sort((a, b) => a.login.localeCompare(b.login, 'ru', { sensitivity: 'base' })); +} + +function positionRows(nodes, x, yStart, yEnd) { + const ys = spread(nodes.length, yStart, yEnd); + return nodes.map((node, index) => ({ ...node, x, y: ys[index] })); +} + +function layoutNodes(model) { + const centerNode = { + id: CENTER_NODE_ID, + login: model.centerLogin, + x: 50, + y: 50, + isCenter: true, + kind: 'center', + relation: null, + mark: model.centerMark, + }; + + const parents = sortByLogin(model.relations.filter((item) => item.role === 'parent')); + const children = sortByLogin(model.relations.filter((item) => item.role === 'child')); + const siblings = sortByLogin(model.relations.filter((item) => item.role === 'sibling')); + const friends = sortByLogin(model.relations.filter((item) => item.role === 'friend')); + + const parentSplit = splitByGender(parents); + const childSplit = splitByGender(children); + const siblingSplit = splitByGender(siblings); + + const friendLeft = []; + const friendRight = []; + friends.forEach((item, index) => { + if (index % 2 === 0) friendLeft.push(item); + else friendRight.push(item); + }); + + const positioned = [ + ...positionRows(parentSplit.left, 28, 14, 30), + ...positionRows(parentSplit.right, 72, 14, 30), + ...positionRows(parentSplit.center, 50, 10, 22), + ...positionRows(friendLeft, 12, 28, 72), + ...positionRows(friendRight, 88, 28, 72), + ...positionRows(siblingSplit.left, 30, 48, 70), + ...positionRows(siblingSplit.right, 70, 48, 70), + ...positionRows(siblingSplit.center, 50, 58, 74), + ...positionRows(childSplit.left, 28, 70, 90), + ...positionRows(childSplit.right, 72, 70, 90), + ...positionRows(childSplit.center, 50, 82, 94), + ]; + + const nodes = [centerNode]; + const edges = []; + + positioned.forEach((item) => { + const nodeId = item.key; + nodes.push({ + id: nodeId, + login: item.login, + x: item.x, + y: item.y, + isCenter: false, + kind: item.isRelative ? 'relative' : 'friend', + relation: item.role, + mark: item.mark, + }); + edges.push({ + from: item.forward ? CENTER_NODE_ID : nodeId, + to: item.forward ? nodeId : CENTER_NODE_ID, + mutual: item.forward && item.backward, + isRelative: item.isRelative, + exists: item.forward || item.backward, + }); + }); + + return { nodes, edges }; +} + +function marker(svg, id, color) { + const el = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); + el.setAttribute('id', id); + el.setAttribute('viewBox', '0 0 10 10'); + el.setAttribute('refX', '9'); + el.setAttribute('refY', '5'); + el.setAttribute('markerUnits', 'strokeWidth'); + el.setAttribute('markerWidth', '6'); + el.setAttribute('markerHeight', '6'); + el.setAttribute('orient', 'auto'); + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M 0 0 L 10 5 L 0 10 z'); + path.setAttribute('fill', color); + el.append(path); + svg.append(el); +} + +function getNodeCenter(boardRect, node) { + const dot = node.querySelector('.node-dot'); + if (!(dot instanceof HTMLElement)) return null; + const rect = dot.getBoundingClientRect(); + return { + x: rect.left - boardRect.left + (rect.width / 2), + y: rect.top - boardRect.top + (rect.height / 2), + radius: rect.width / 2, + }; +} + +function shortenLine(fromPoint, toPoint, fromOffset, toOffset) { + const dx = toPoint.x - fromPoint.x; + const dy = toPoint.y - fromPoint.y; + const len = Math.hypot(dx, dy); + if (len < 1) return null; + const ux = dx / len; + const uy = dy / len; + return { + x1: fromPoint.x + (ux * fromOffset), + y1: fromPoint.y + (uy * fromOffset), + x2: toPoint.x - (ux * toOffset), + y2: toPoint.y - (uy * toOffset), + }; +} + +function renderEdges(svg, board, nodeElements, edges) { + svg.innerHTML = ''; + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + marker(defs, 'network-arrow-friend', 'rgba(120, 179, 255, 0.95)'); + marker(defs, 'network-arrow-relative', 'rgba(255, 159, 94, 0.95)'); + svg.append(defs); + + const boardRect = board.getBoundingClientRect(); + const centers = new Map(); + nodeElements.forEach((value, key) => { + const pt = getNodeCenter(boardRect, value); + if (pt) centers.set(key, pt); + }); + + edges.forEach((edge) => { + if (!edge.exists) return; + const from = centers.get(edge.from); + const to = centers.get(edge.to); + if (!from || !to) return; + + const cut = shortenLine(from, to, from.radius + 3, to.radius + 3); + if (!cut) return; + + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', String(cut.x1)); + line.setAttribute('y1', String(cut.y1)); + line.setAttribute('x2', String(cut.x2)); + line.setAttribute('y2', String(cut.y2)); + line.setAttribute('class', `network-link ${edge.isRelative ? 'is-relative' : 'is-friend'}`); + + if (!edge.mutual) { + line.setAttribute('marker-end', `url(#${edge.isRelative ? 'network-arrow-relative' : 'network-arrow-friend'})`); + } + svg.append(line); + }); } export function render({ navigate }) { @@ -27,8 +363,18 @@ export function render({ navigate }) { note.className = 'meta-muted'; note.textContent = 'Загрузка связей...'; + const legend = document.createElement('div'); + legend.className = 'network-legend'; + legend.innerHTML = ` + Друзья + Родственники + Односторонняя связь + `; + let activeMenu = null; let centerLogin = state.session.login || ''; + let redrawEdges = () => {}; + let loadSeq = 0; function closeNodeMenu() { if (!activeMenu) return; @@ -51,7 +397,7 @@ export function render({ navigate }) { menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`; const btn = menu.querySelector('button'); - btn.addEventListener('click', () => { + btn?.addEventListener('click', () => { navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`); closeNodeMenu(); }); @@ -67,10 +413,9 @@ export function render({ navigate }) { let longPressTriggered = false; const clearTimer = () => { - if (timerId) { - window.clearTimeout(timerId); - timerId = 0; - } + if (!timerId) return; + window.clearTimeout(timerId); + timerId = 0; }; node.addEventListener('pointerdown', (event) => { @@ -95,7 +440,6 @@ export function render({ navigate }) { node.addEventListener('pointerleave', clearTimer); node.addEventListener('pointercancel', clearTimer); - node.addEventListener('pointerup', (event) => { if (event.button !== 0) return; clearTimer(); @@ -105,54 +449,46 @@ export function render({ navigate }) { } async function load(nextCenterLogin = '') { - const targetCenter = nextCenterLogin || centerLogin || state.session.login; + const requestId = ++loadSeq; + const targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login); centerLogin = targetCenter; closeNodeMenu(); note.textContent = 'Загрузка связей...'; try { const graph = await authService.getUserConnectionsGraph(targetCenter); + if (requestId !== loadSeq) return; + + const model = buildGraphModel(graph, targetCenter); + const layout = layoutNodes(model); + board.innerHTML = ''; - - const center = makeNode(graph.login || targetCenter, 'center'); - center.style.left = '50%'; - center.style.top = '50%'; - board.append(center); - - const all = unique([...(graph.outFriends || []), ...(graph.inFriends || [])]); - const left = all.slice(0, Math.ceil(all.length / 2)); - const right = all.slice(Math.ceil(all.length / 2)); - - const mk = (name, side, idx, total) => { - const node = makeNode(name); - const y = 15 + ((idx + 1) * 70) / (Math.max(total, 1) + 1); - node.style.left = side === 'left' ? '20%' : '80%'; - node.style.top = `${y}%`; - bindNodeInteraction(node, name, load); - board.append(node); - - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('x1', '50'); - line.setAttribute('y1', '50'); - line.setAttribute('x2', side === 'left' ? '20' : '80'); - line.setAttribute('y2', String(y)); - line.setAttribute('stroke', 'rgba(125,170,255,0.6)'); - line.setAttribute('stroke-width', '1.5'); - return line; - }; - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'network-svg'); - svg.setAttribute('viewBox', '0 0 100 100'); - svg.setAttribute('preserveAspectRatio', 'none'); + board.append(svg); - left.forEach((name, i) => svg.append(mk(name, 'left', i, left.length))); - right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length))); - board.prepend(svg); + const nodeElements = new Map(); + layout.nodes.forEach((nodeModel) => { + const node = buildNodeElement({ + login: nodeModel.login, + kind: nodeModel.kind, + isCenter: nodeModel.isCenter, + mark: nodeModel.mark, + }); + node.style.left = `${nodeModel.x}%`; + node.style.top = `${nodeModel.y}%`; + board.append(node); + nodeElements.set(nodeModel.id, node); + if (!nodeModel.isCenter) bindNodeInteraction(node, nodeModel.login, load); + }); - note.textContent = 'Тап по узлу: информация о пользователе. Долгое нажатие: центрировать граф.'; - } catch (e) { - note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`; + redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges); + requestAnimationFrame(() => redrawEdges()); + + note.textContent = 'Тап: информация о пользователе. Долгое нажатие: сделать узел центром. Линия = взаимно, стрелка = в одну сторону.'; + } catch (error) { + if (requestId !== loadSeq) return; + note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`; } } @@ -163,8 +499,20 @@ export function render({ navigate }) { closeNodeMenu(); }; document.addEventListener('pointerdown', outsideTapHandler, true); + + const onResize = () => redrawEdges(); + window.addEventListener('resize', onResize); + + let observer = null; + if (typeof ResizeObserver !== 'undefined') { + observer = new ResizeObserver(() => redrawEdges()); + observer.observe(board); + } + screen.cleanup = () => { document.removeEventListener('pointerdown', outsideTapHandler, true); + window.removeEventListener('resize', onResize); + if (observer) observer.disconnect(); }; board.addEventListener('pointerdown', (event) => { @@ -176,7 +524,6 @@ export function render({ navigate }) { }); load(); - - screen.append(renderHeader({ title: 'Связи' }), board, note); + screen.append(renderHeader({ title: 'Связи' }), legend, board, note); return screen; } diff --git a/shine-UI/js/pages/profile-view.js b/shine-UI/js/pages/profile-view.js index bf65422..0bfa2f1 100644 --- a/shine-UI/js/pages/profile-view.js +++ b/shine-UI/js/pages/profile-view.js @@ -1,6 +1,6 @@ import { renderHeader } from '../components/header.js'; import { profile } from '../mock-data.js'; -import { state } from '../state.js'; +import { authService, state } from '../state.js'; import { PROFILE_GENDER_FEMALE, PROFILE_GENDER_MALE, @@ -10,7 +10,7 @@ import { saveProfileParamBlock, saveProfileToggle, } from '../services/user-profile-params.js'; -import { buildIdentityLines } from '../services/user-connections.js'; +import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js'; export const pageMeta = { id: 'profile-view', title: 'Профиль' }; @@ -36,6 +36,40 @@ const GENDER_OPTIONS = Object.freeze([ { value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' }, ]); +const RELATIVE_RELATION_OPTIONS = Object.freeze([ + { value: 'parent', label: 'Родитель (мать/отец по полу)' }, + { value: 'child', label: 'Ребёнок (сын/дочь по полу)' }, + { value: 'sibling', label: 'Брат или сестра (по полу)' }, + { value: 'close_friend', label: 'Близкий друг' }, +]); + +function normalizeGender(value) { + const v = String(value || '').trim().toLowerCase(); + if (v === PROFILE_GENDER_MALE) return PROFILE_GENDER_MALE; + if (v === PROFILE_GENDER_FEMALE) return PROFILE_GENDER_FEMALE; + return PROFILE_GENDER_UNKNOWN; +} + +function relationAccusativeLabel(type, targetGender) { + const gender = normalizeGender(targetGender); + if (type === 'parent') { + if (gender === PROFILE_GENDER_MALE) return 'отца'; + if (gender === PROFILE_GENDER_FEMALE) return 'мать'; + return 'родителя'; + } + if (type === 'child') { + if (gender === PROFILE_GENDER_MALE) return 'сына'; + if (gender === PROFILE_GENDER_FEMALE) return 'дочь'; + return 'ребёнка'; + } + if (type === 'sibling') { + if (gender === PROFILE_GENDER_MALE) return 'брата'; + if (gender === PROFILE_GENDER_FEMALE) return 'сестру'; + return 'брата/сестру'; + } + return 'близкого друга'; +} + function escapeHtml(text) { return String(text || '') .replaceAll('&', '&') @@ -90,9 +124,21 @@ export function render({ navigate }) { const listWrap = document.createElement('div'); listWrap.className = 'stack profile-param-list'; + const relativesCard = document.createElement('div'); + relativesCard.className = 'card stack'; + relativesCard.innerHTML = ` +
Близкие родственники
+
+ Добавьте связь: родитель, ребёнок, брат/сестра или близкий друг. + Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя. +
+ + `; + const reloadBtn = topRow.querySelector('[data-reload="true"]'); const officialBtn = badgesRow.querySelector('[data-toggle="official"]'); const shineBtn = badgesRow.querySelector('[data-toggle="shine"]'); + const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]'); let currentFields = []; let currentToggles = []; @@ -248,6 +294,98 @@ export function render({ navigate }) { }); } + function openAddRelativeModal({ contacts = [] } = {}) { + const root = document.getElementById('modal-root'); + if (!root) return Promise.resolve(null); + const preparedContacts = Array.isArray(contacts) + ? contacts + .map((item) => String(item || '').trim()) + .filter(Boolean) + : []; + const uniqueContacts = Array.from(new Set(preparedContacts.map((item) => item.toLowerCase()))) + .map((key) => preparedContacts.find((item) => item.toLowerCase() === key)) + .filter(Boolean); + const datalistId = `profile-relative-contacts-${Math.random().toString(16).slice(2, 9)}`; + + root.innerHTML = ` + + `; + + return new Promise((resolve) => { + const modal = root.querySelector('#profile-add-relative-modal'); + const kindEl = root.querySelector('#profile-relative-kind'); + const loginEl = root.querySelector('#profile-relative-login'); + const submitEl = root.querySelector('#profile-relative-submit'); + const cancelEl = root.querySelector('#profile-relative-cancel'); + const errorEl = root.querySelector('#profile-relative-error'); + + if (!(modal instanceof HTMLElement) || !(kindEl instanceof HTMLSelectElement) || !(loginEl instanceof HTMLInputElement)) { + root.innerHTML = ''; + resolve(null); + return; + } + + const close = (payload = null) => { + root.innerHTML = ''; + resolve(payload); + }; + + const submit = () => { + const relationType = String(kindEl.value || '').trim(); + const toLogin = String(loginEl.value || '').trim(); + if (!relationType) { + if (errorEl) errorEl.textContent = 'Выберите тип связи.'; + return; + } + if (!toLogin) { + if (errorEl) errorEl.textContent = 'Введите логин пользователя.'; + return; + } + close({ relationType, toLogin }); + }; + + modal.addEventListener('click', (event) => { + if (event.target === modal) close(null); + }); + cancelEl?.addEventListener('click', () => close(null)); + submitEl?.addEventListener('click', submit); + loginEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } + }); + window.setTimeout(() => loginEl.focus(), 0); + }); + } + function renderFields(fields) { listWrap.innerHTML = ''; fields.forEach((field) => { @@ -280,6 +418,7 @@ export function render({ navigate }) { reloadBtn.disabled = true; officialBtn.disabled = true; shineBtn.disabled = true; + if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true; const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]'); if (genderActionBtn instanceof HTMLButtonElement) { genderActionBtn.disabled = true; @@ -306,6 +445,7 @@ export function render({ navigate }) { reloadBtn.disabled = false; officialBtn.disabled = false; shineBtn.disabled = false; + if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false; const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]'); if (genderActionBtnAfter instanceof HTMLButtonElement) { genderActionBtnAfter.disabled = false; @@ -378,6 +518,88 @@ export function render({ navigate }) { } } + async function onAddRelativeClick() { + const sessionLogin = String(login || '').trim(); + if (!sessionLogin) { + window.alert('Для добавления связи нужен активный логин.'); + return; + } + + status.className = 'status-line'; + status.textContent = 'Подготовка окна добавления связи...'; + + let contacts = []; + try { + const payload = await authService.listContacts(); + contacts = Array.isArray(payload?.contacts) ? payload.contacts : []; + } catch { + contacts = []; + } + + const picked = await openAddRelativeModal({ contacts }); + if (!picked) { + status.className = 'status-line is-available'; + status.textContent = 'Добавление связи отменено.'; + return; + } + + const relationType = String(picked.relationType || '').trim().toLowerCase(); + const inputLogin = String(picked.toLogin || '').trim(); + if (!relationType || !inputLogin) return; + if (inputLogin.toLowerCase() === sessionLogin.toLowerCase()) { + status.className = 'status-line is-unavailable'; + status.textContent = 'Нельзя добавить связь с самим собой.'; + return; + } + + status.className = 'status-line'; + status.textContent = 'Проверка пользователя...'; + + let targetCard; + try { + targetCard = await loadUserProfileCard(inputLogin); + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Пользователь не найден: ${error.message || 'unknown'}`; + showLocalErrorAlert('Ошибка выбора пользователя', error); + return; + } + + const targetLogin = String(targetCard?.login || inputLogin).trim(); + const targetGender = normalizeGender(targetCard?.gender || PROFILE_GENDER_UNKNOWN); + const relationLabel = relationAccusativeLabel(relationType, targetGender); + const confirmed = window.confirm( + `Добавить пользователя ${targetLogin} как ${relationLabel}?`, + ); + if (!confirmed) return; + + status.className = 'status-line'; + status.textContent = 'Сохранение связи...'; + + try { + if (relationType === 'close_friend') { + await authService.addCloseFriend(targetLogin); + } else { + if (!state.session.storagePwdInMemory) { + throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.'); + } + await authService.setUserRelation({ + login: sessionLogin, + toLogin: targetLogin, + kind: relationType, + enabled: true, + storagePwd: state.session.storagePwdInMemory, + }); + } + status.className = 'status-line is-available'; + status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`; + } catch (error) { + status.className = 'status-line is-unavailable'; + status.textContent = `Не удалось добавить связь: ${error.message || 'ошибка сети'}`; + showLocalErrorAlert('Ошибка добавления связи', error); + } + } + listWrap.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; @@ -393,8 +615,9 @@ export function render({ navigate }) { reloadBtn.addEventListener('click', refreshProfileSnapshot); officialBtn.addEventListener('click', () => onToggleClick('official')); shineBtn.addEventListener('click', () => onToggleClick('shine')); + addRelativeBtn?.addEventListener('click', onAddRelativeClick); - card.append(topRow, badgesRow, status, listWrap); + card.append(topRow, badgesRow, status, listWrap, relativesCard); screen.append(card); refreshProfileSnapshot(); diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index 614e746..e97c4a6 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -48,6 +48,9 @@ const CONNECTION_SUBTYPES = Object.freeze({ friend: { on: 10, off: 11 }, contact: { on: 20, off: 21 }, follow: { on: 30, off: 31 }, + parent: { on: 50, off: 51 }, + child: { on: 52, off: 53 }, + sibling: { on: 54, off: 55 }, }); function normalizeServerUrl(url) { diff --git a/shine-UI/js/services/user-connections.js b/shine-UI/js/services/user-connections.js index 453a962..2b37f0d 100644 --- a/shine-UI/js/services/user-connections.js +++ b/shine-UI/js/services/user-connections.js @@ -68,6 +68,12 @@ async function buildRelationsModel(login) { inContacts: [], outFollows: [], inFollows: [], + outParents: [], + inParents: [], + outChildren: [], + inChildren: [], + outSiblings: [], + inSiblings: [], }; } @@ -104,6 +110,12 @@ async function buildRelationsModel(login) { inContacts: readArray(graph, 'inContacts') || [], outFollows, inFollows: readArray(graph, 'inFollows') || [], + outParents: readArray(graph, 'outParents') || [], + inParents: readArray(graph, 'inParents') || [], + outChildren: readArray(graph, 'outChildren') || [], + inChildren: readArray(graph, 'inChildren') || [], + outSiblings: readArray(graph, 'outSiblings') || [], + inSiblings: readArray(graph, 'inSiblings') || [], }; } @@ -144,6 +156,12 @@ export async function loadCurrentRelations() { inContacts: [], outFollows: [], inFollows: [], + outParents: [], + inParents: [], + outChildren: [], + inChildren: [], + outSiblings: [], + inSiblings: [], }; } return buildRelationsModel(login); @@ -157,6 +175,12 @@ export function relationFlagsForTarget(relations, targetLogin) { inContact: listContainsLogin(relations?.inContacts, targetLogin), outFollow: listContainsLogin(relations?.outFollows, targetLogin), inFollow: listContainsLogin(relations?.inFollows, targetLogin), + outParent: listContainsLogin(relations?.outParents, targetLogin), + inParent: listContainsLogin(relations?.inParents, targetLogin), + outChild: listContainsLogin(relations?.outChildren, targetLogin), + inChild: listContainsLogin(relations?.inChildren, targetLogin), + outSibling: listContainsLogin(relations?.outSiblings, targetLogin), + inSibling: listContainsLogin(relations?.inSiblings, targetLogin), }; } diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index a996d38..aa7f7a4 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -253,7 +253,8 @@ font-size: 13px; } -.profile-gender-select { +.profile-gender-select, +.profile-relation-select { min-height: 46px; border-radius: 12px; border: 1px solid rgba(157, 185, 238, 0.35); @@ -264,7 +265,8 @@ letter-spacing: 0.01em; } -.profile-gender-select:focus { +.profile-gender-select:focus, +.profile-relation-select:focus { border-color: rgba(120, 211, 255, 0.9); box-shadow: 0 0 0 3px rgba(65, 174, 255, 0.2); } @@ -846,16 +848,74 @@ textarea.input { overflow: hidden; } +.network-legend { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 12px; + color: #bfd2ff; +} + +.network-legend span { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.legend-line, +.legend-arrow { + width: 18px; + height: 2px; + display: inline-block; + border-radius: 2px; + background: rgba(120, 179, 255, 0.95); + position: relative; +} + +.legend-line.relative { + background: rgba(255, 159, 94, 0.95); +} + +.legend-arrow::after { + content: ''; + position: absolute; + right: -1px; + top: -3px; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 6px solid rgba(120, 179, 255, 0.95); +} + .network-svg { position: absolute; inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.network-link { + stroke-width: 2; +} + +.network-link.is-friend { + stroke: rgba(120, 179, 255, 0.88); +} + +.network-link.is-relative { + stroke: rgba(255, 159, 94, 0.9); } .node { position: absolute; transform: translate(-50%, -50%); - width: 74px; + width: 90px; + border: 0; + background: transparent; + padding: 0; text-align: center; + color: inherit; + cursor: pointer; } .node-dot { @@ -866,8 +926,18 @@ textarea.input { display: grid; place-items: center; font-weight: 700; - background: #2f4265; + background: #2b3f66; border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: 0 8px 16px rgba(4, 8, 15, 0.35); +} + +.node.is-friend .node-dot { + background: linear-gradient(165deg, #2f4f80, #2a3f62); +} + +.node.is-relative .node-dot { + background: linear-gradient(165deg, #785038, #5f3e2c); + border-color: rgba(255, 194, 143, 0.55); } .node.center .node-dot { @@ -880,6 +950,13 @@ textarea.input { .node-label { font-size: 11px; color: #d6e2ff; + text-shadow: 0 1px 0 rgba(0, 0, 0, 0.28); +} + +.node:focus-visible .node-dot, +.node:hover .node-dot { + border-color: rgba(166, 218, 255, 0.92); + box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.2), 0 8px 16px rgba(4, 8, 15, 0.35); } .node-menu { diff --git a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java index 73d1925..4b983eb 100644 --- a/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java +++ b/shine-server-blockchain/src/main/java/blockchain/MsgSubType.java @@ -88,6 +88,21 @@ public final class MsgSubType { /** Отписаться (unfollow). */ public static final short CONNECTION_UNFOLLOW = 31; + /** Добавить связь "родитель". */ + public static final short CONNECTION_PARENT = 50; + /** Удалить связь "родитель". */ + public static final short CONNECTION_UNPARENT = 51; + + /** Добавить связь "ребёнок". */ + public static final short CONNECTION_CHILD = 52; + /** Удалить связь "ребёнок". */ + public static final short CONNECTION_UNCHILD = 53; + + /** Добавить связь "брат/сестра". */ + public static final short CONNECTION_SIBLING = 54; + /** Удалить связь "брат/сестра". */ + public static final short CONNECTION_UNSIBLING = 55; + /* ===================== USER_PARAM (msg_type=4) ===================== */ /** Параметр профиля key/value (обе строки). */ diff --git a/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java b/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java index bca7cb9..cbac35a 100644 --- a/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java +++ b/shine-server-blockchain/src/main/java/blockchain/body/ConnectionBody.java @@ -16,6 +16,9 @@ import java.util.Objects; * FRIEND=10, UNFRIEND=11 * CONTACT=20, UNCONTACT=21 * FOLLOW=30, UNFOLLOW=31 + * PARENT=50, UNPARENT=51 + * CHILD=52, UNCHILD=53 + * SIBLING=54, UNSIBLING=55 * * bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ): * [4] lineCode @@ -181,7 +184,13 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL || v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF) || v == (MsgSubType.CONNECTION_FOLLOW & 0xFFFF) - || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF); + || v == (MsgSubType.CONNECTION_UNFOLLOW & 0xFFFF) + || v == (MsgSubType.CONNECTION_PARENT & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNPARENT & 0xFFFF) + || v == (MsgSubType.CONNECTION_CHILD & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNCHILD & 0xFFFF) + || v == (MsgSubType.CONNECTION_SIBLING & 0xFFFF) + || v == (MsgSubType.CONNECTION_UNSIBLING & 0xFFFF); } @Override @@ -256,4 +265,4 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL @Override public String toBchName() { return toBlockchainName; } @Override public Integer toBlockGlobalNumber() { return toBlockGlobalNumber; } @Override public byte[] toBlockHashBytes() { return toBlockHash32; } -} \ No newline at end of file +} diff --git a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java index c367031..b89ed1a 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseInitializer.java @@ -48,6 +48,15 @@ public final class DatabaseInitializer { public static final short CONNECTION_FOLLOW = 30; public static final short CONNECTION_UNFOLLOW = 31; + public static final short CONNECTION_PARENT = 50; + public static final short CONNECTION_UNPARENT = 51; + + public static final short CONNECTION_CHILD = 52; + public static final short CONNECTION_UNCHILD = 53; + + public static final short CONNECTION_SIBLING = 54; + public static final short CONNECTION_UNSIBLING = 55; + public static void createNewDB(String[] args) { AppConfig config = AppConfig.getInstance(); String dbPath = config.getParam("db.path"); diff --git a/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java b/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java index 8252e3f..b5b9312 100644 --- a/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java +++ b/shine-server-db/src/main/java/shine/db/DatabaseTriggersInstaller.java @@ -194,17 +194,23 @@ public final class DatabaseTriggersInstaller { int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND; int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT; int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW; + int PARENT = (int) DatabaseInitializer.CONNECTION_PARENT; + int CHILD = (int) DatabaseInitializer.CONNECTION_CHILD; + int SIBLING = (int) DatabaseInitializer.CONNECTION_SIBLING; int UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND; int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT; int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW; + int UNPARENT = (int) DatabaseInitializer.CONNECTION_UNPARENT; + int UNCHILD = (int) DatabaseInitializer.CONNECTION_UNCHILD; + int UNSIBLING = (int) DatabaseInitializer.CONNECTION_UNSIBLING; st.executeUpdate(""" CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai AFTER INSERT ON blocks WHEN NEW.msg_type = 3 BEGIN - -- FRIEND/CONTACT/FOLLOW: + -- FRIEND/CONTACT/FOLLOW/PARENT/CHILD/SIBLING: -- 1) если записи нет — создаём INSERT OR IGNORE INTO connections_state ( login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash @@ -225,7 +231,7 @@ public final class DatabaseTriggersInstaller { NEW.to_bch_name, NEW.to_block_number, NEW.to_block_hash - WHERE NEW.msg_sub_type IN (%d, %d, %d) + WHERE NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d) AND COALESCE( NEW.to_login, CASE @@ -256,7 +262,7 @@ public final class DatabaseTriggersInstaller { ELSE NULL END ) - AND NEW.msg_sub_type IN (%d, %d) + AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d) AND COALESCE( NEW.to_login, CASE @@ -269,7 +275,7 @@ public final class DatabaseTriggersInstaller { ) IS NOT NULL AND NEW.to_bch_name IS NOT NULL; - -- UNFRIEND/UNCONTACT/UNFOLLOW: + -- UNFRIEND/UNCONTACT/UNFOLLOW/UNPARENT/UNCHILD/UNSIBLING: -- удаляем соответствующее "позитивное" состояние DELETE FROM connections_state WHERE login = NEW.login @@ -284,6 +290,9 @@ public final class DatabaseTriggersInstaller { END ) AND rel_type = CASE NEW.msg_sub_type + WHEN %d THEN %d + WHEN %d THEN %d + WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d WHEN %d THEN %d @@ -299,17 +308,20 @@ public final class DatabaseTriggersInstaller { ELSE NULL END ) IS NOT NULL - AND NEW.msg_sub_type IN (%d, %d, %d); + AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d, %d); END; """.formatted( - FRIEND, CONTACT, FOLLOW, - FRIEND, CONTACT, + FRIEND, CONTACT, FOLLOW, PARENT, CHILD, SIBLING, + FRIEND, CONTACT, PARENT, CHILD, SIBLING, UNFRIEND, FRIEND, UNCONTACT, CONTACT, UNFOLLOW, FOLLOW, + UNPARENT, PARENT, + UNCHILD, CHILD, + UNSIBLING, SIBLING, - UNFRIEND, UNCONTACT, UNFOLLOW + UNFRIEND, UNCONTACT, UNFOLLOW, UNPARENT, UNCHILD, UNSIBLING )); } @@ -503,4 +515,3 @@ public final class DatabaseTriggersInstaller { """.formatted(EDIT_POST, EDIT_REPLY)); } } - diff --git a/shine-server-db/src/main/java/shine/db/MsgSubType.java b/shine-server-db/src/main/java/shine/db/MsgSubType.java index a440aec..5f63269 100644 --- a/shine-server-db/src/main/java/shine/db/MsgSubType.java +++ b/shine-server-db/src/main/java/shine/db/MsgSubType.java @@ -40,8 +40,8 @@ public final class MsgSubType { /* ===================== CONNECTION (msg_type=3) ===================== */ /** * Совпадает с ConnectionBody: - * SET: FRIEND=10, CONTACT=20, FOLLOW=30 - * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31 + * SET: FRIEND=10, CONTACT=20, FOLLOW=30, PARENT=50, CHILD=52, SIBLING=54 + * UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNPARENT=51, UNCHILD=53, UNSIBLING=55 */ /** Добавить в друзья. */ @@ -62,6 +62,24 @@ public final class MsgSubType { /** Отписаться (unfollow). */ public static final short CONNECTION_UNFOLLOW = 31; + /** Добавить связь "родитель". */ + public static final short CONNECTION_PARENT = 50; + + /** Удалить связь "родитель". */ + public static final short CONNECTION_UNPARENT = 51; + + /** Добавить связь "ребёнок". */ + public static final short CONNECTION_CHILD = 52; + + /** Удалить связь "ребёнок". */ + public static final short CONNECTION_UNCHILD = 53; + + /** Добавить связь "брат/сестра". */ + public static final short CONNECTION_SIBLING = 54; + + /** Удалить связь "брат/сестра". */ + public static final short CONNECTION_UNSIBLING = 55; + /* ===================== USER_PARAM (msg_type=4) ===================== */ /** Параметр профиля key/value (обе строки). */ diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java index d3510ea..b8f6272 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/Net_GetUserConnectionsGraph_Handler.java @@ -14,7 +14,13 @@ import shine.db.dao.ConnectionsStateDAO; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { @Override @@ -37,6 +43,22 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { List inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT); List outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW); List inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW); + List outParents = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_PARENT); + List inParents = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_PARENT); + List outChildren = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD); + List inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD); + List outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); + List inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING); + + LinkedHashSet allLogins = new LinkedHashSet<>(); + allLogins.add(canonicalLogin); + addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows, + outParents, inParents, outChildren, inChildren, outSiblings, inSiblings); + + Map metaByLogin = loadUserMeta(c, allLogins); + List parentLogins = mergeUnique(outParents, inChildren); + List childLogins = mergeUnique(outChildren, inParents); + List siblingLogins = mergeUnique(outSiblings, inSiblings); Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response(); resp.setOp(req.getOp()); @@ -49,6 +71,16 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { resp.setInContacts(inContacts); resp.setOutFollows(outFollows); resp.setInFollows(inFollows); + resp.setOutParents(outParents); + resp.setInParents(inParents); + resp.setOutChildren(outChildren); + resp.setInChildren(inChildren); + resp.setOutSiblings(outSiblings); + resp.setInSiblings(inSiblings); + resp.setParents(toRelativeItems(parentLogins, metaByLogin)); + resp.setChildren(toRelativeItems(childLogins, metaByLogin)); + resp.setSiblings(toRelativeItems(siblingLogins, metaByLogin)); + resp.setAllUsers(toUserMarkItems(allLogins, metaByLogin)); return resp; } } @@ -62,4 +94,150 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler { } } } + + @SafeVarargs + private final void addAllLogins(Set target, List... lists) { + if (target == null || lists == null) return; + for (List list : lists) { + if (list == null) continue; + for (String login : list) { + String clean = (login == null) ? "" : login.trim(); + if (!clean.isEmpty()) target.add(clean); + } + } + } + + private List mergeUnique(List first, List second) { + LinkedHashMap byKey = new LinkedHashMap<>(); + addToMergeMap(byKey, first); + addToMergeMap(byKey, second); + return new ArrayList<>(byKey.values()); + } + + private void addToMergeMap(Map target, List source) { + if (target == null || source == null) return; + for (String login : source) { + String clean = (login == null) ? "" : login.trim(); + if (clean.isEmpty()) continue; + String key = normKey(clean); + if (!target.containsKey(key)) target.put(key, clean); + } + } + + private String normKey(String login) { + return String.valueOf(login == null ? "" : login).trim().toLowerCase(); + } + + private Map loadUserMeta(Connection c, Set logins) throws Exception { + Map out = new HashMap<>(); + if (logins == null || logins.isEmpty()) return out; + + String[] placeholders = new String[logins.size()]; + for (int i = 0; i < placeholders.length; i += 1) placeholders[i] = "?"; + String sql = """ + SELECT + su.login AS login, + MAX(CASE WHEN up.param = 'gender' THEN up.value END) AS gender_value, + MAX(CASE WHEN up.param = 'official' THEN up.value END) AS official_value, + MAX(CASE WHEN up.param = 'shine' THEN up.value END) AS shine_value + FROM solana_users su + LEFT JOIN users_params up + ON up.login = su.login COLLATE NOCASE + AND up.param IN ('gender', 'official', 'shine') + WHERE su.login COLLATE NOCASE IN (%s) + GROUP BY su.login + ORDER BY su.login + """.formatted(String.join(", ", placeholders)); + + try (PreparedStatement ps = c.prepareStatement(sql)) { + int i = 1; + for (String login : logins) { + ps.setString(i, login); + i += 1; + } + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String login = rs.getString("login"); + if (login == null || login.isBlank()) continue; + UserMeta meta = new UserMeta(); + meta.gender = normalizeGender(rs.getString("gender_value")); + meta.official = parseToggle(rs.getString("official_value")); + meta.shine = parseToggle(rs.getString("shine_value")); + out.put(normKey(login), meta); + } + } + } + return out; + } + + private boolean parseToggle(String rawValue) { + String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase(); + return "1".equals(v) || "yes".equals(v) || "true".equals(v) || "on".equals(v); + } + + private String normalizeGender(String rawValue) { + String v = String.valueOf(rawValue == null ? "" : rawValue).trim().toLowerCase(); + if ("male".equals(v)) return "male"; + if ("female".equals(v)) return "female"; + return "unknown"; + } + + private String genderLabel(String gender) { + if ("male".equals(gender)) return "мужчина"; + if ("female".equals(gender)) return "женщина"; + return "не указан"; + } + + private List toRelativeItems( + List logins, + Map metaByLogin + ) { + List items = new ArrayList<>(); + if (logins == null) return items; + for (String login : logins) { + String clean = (login == null) ? "" : login.trim(); + if (clean.isEmpty()) continue; + UserMeta meta = metaByLogin.get(normKey(clean)); + String gender = (meta == null) ? "unknown" : meta.gender; + + Net_GetUserConnectionsGraph_Response.RelativeItem it = new Net_GetUserConnectionsGraph_Response.RelativeItem(); + it.setLogin(clean); + it.setGender(gender); + it.setGenderLabel(genderLabel(gender)); + items.add(it); + } + return items; + } + + private List toUserMarkItems( + Set logins, + Map metaByLogin + ) { + List items = new ArrayList<>(); + if (logins == null) return items; + + for (String login : logins) { + String clean = (login == null) ? "" : login.trim(); + if (clean.isEmpty()) continue; + + UserMeta meta = metaByLogin.get(normKey(clean)); + boolean official = meta != null && meta.official; + boolean shine = meta != null && meta.shine; + + Net_GetUserConnectionsGraph_Response.UserMarkItem it = new Net_GetUserConnectionsGraph_Response.UserMarkItem(); + it.setLogin(clean); + it.setOfficial(official); + it.setShine(shine); + it.setOfficialLabel(official ? "официальный" : "неофициальный"); + it.setShineLabel(shine ? "сияющий" : "несияющий"); + items.add(it); + } + return items; + } + + private static final class UserMeta { + private String gender = "unknown"; + private boolean official = false; + private boolean shine = false; + } } diff --git a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java index 120e5f7..a8e7362 100644 --- a/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java +++ b/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/connections/entyties/Net_GetUserConnectionsGraph_Response.java @@ -13,6 +13,48 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response { private List inContacts = new ArrayList<>(); private List outFollows = new ArrayList<>(); private List inFollows = new ArrayList<>(); + private List outParents = new ArrayList<>(); + private List inParents = new ArrayList<>(); + private List outChildren = new ArrayList<>(); + private List inChildren = new ArrayList<>(); + private List outSiblings = new ArrayList<>(); + private List inSiblings = new ArrayList<>(); + private List parents = new ArrayList<>(); + private List children = new ArrayList<>(); + private List siblings = new ArrayList<>(); + private List allUsers = new ArrayList<>(); + + public static class RelativeItem { + private String login; + private String gender; + private String genderLabel; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public String getGender() { return gender; } + public void setGender(String gender) { this.gender = gender; } + public String getGenderLabel() { return genderLabel; } + public void setGenderLabel(String genderLabel) { this.genderLabel = genderLabel; } + } + + public static class UserMarkItem { + private String login; + private boolean official; + private boolean shine; + private String officialLabel; + private String shineLabel; + + public String getLogin() { return login; } + public void setLogin(String login) { this.login = login; } + public boolean isOfficial() { return official; } + public void setOfficial(boolean official) { this.official = official; } + public boolean isShine() { return shine; } + public void setShine(boolean shine) { this.shine = shine; } + public String getOfficialLabel() { return officialLabel; } + public void setOfficialLabel(String officialLabel) { this.officialLabel = officialLabel; } + public String getShineLabel() { return shineLabel; } + public void setShineLabel(String shineLabel) { this.shineLabel = shineLabel; } + } public String getLogin() { return login; } public void setLogin(String login) { this.login = login; } @@ -28,4 +70,24 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response { public void setOutFollows(List outFollows) { this.outFollows = outFollows; } public List getInFollows() { return inFollows; } public void setInFollows(List inFollows) { this.inFollows = inFollows; } + public List getOutParents() { return outParents; } + public void setOutParents(List outParents) { this.outParents = outParents; } + public List getInParents() { return inParents; } + public void setInParents(List inParents) { this.inParents = inParents; } + public List getOutChildren() { return outChildren; } + public void setOutChildren(List outChildren) { this.outChildren = outChildren; } + public List getInChildren() { return inChildren; } + public void setInChildren(List inChildren) { this.inChildren = inChildren; } + public List getOutSiblings() { return outSiblings; } + public void setOutSiblings(List outSiblings) { this.outSiblings = outSiblings; } + public List getInSiblings() { return inSiblings; } + public void setInSiblings(List inSiblings) { this.inSiblings = inSiblings; } + public List getParents() { return parents; } + public void setParents(List parents) { this.parents = parents; } + public List getChildren() { return children; } + public void setChildren(List children) { this.children = children; } + public List getSiblings() { return siblings; } + public void setSiblings(List siblings) { this.siblings = siblings; } + public List getAllUsers() { return allUsers; } + public void setAllUsers(List allUsers) { this.allUsers = allUsers; } }