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; }
}