Добавлены родственные связи, расширен граф связей и улучшен локальный запуск
Что добавлено:\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.
This commit is contained in:
parent
9b188d56e9
commit
4a92a7fa22
12
build.gradle
12
build.gradle
@ -271,3 +271,15 @@ tasks.register('startLocal', Exec) {
|
|||||||
fi
|
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')
|
||||||
|
}
|
||||||
|
|||||||
@ -3,16 +3,352 @@ import { authService, state } from '../state.js';
|
|||||||
|
|
||||||
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
export const pageMeta = { id: 'network-view', title: 'Связи' };
|
||||||
|
|
||||||
function makeNode(name, cls = '') {
|
const GENDER_MALE = 'male';
|
||||||
const n = document.createElement('div');
|
const GENDER_FEMALE = 'female';
|
||||||
n.className = `node ${cls}`.trim();
|
const GENDER_UNKNOWN = 'unknown';
|
||||||
n.dataset.nodeLogin = name;
|
const CENTER_NODE_ID = '__center__';
|
||||||
n.innerHTML = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
|
|
||||||
return n;
|
function normalizeLogin(value) {
|
||||||
|
return String(value || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function unique(list) {
|
function normKey(value) {
|
||||||
return [...new Set((Array.isArray(list) ? list : []).filter(Boolean))];
|
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 = `
|
||||||
|
<span class="node-dot">${(login[0] || '?').toUpperCase()}</span>
|
||||||
|
<span class="node-label">${login}</span>
|
||||||
|
`;
|
||||||
|
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 }) {
|
export function render({ navigate }) {
|
||||||
@ -27,8 +363,18 @@ export function render({ navigate }) {
|
|||||||
note.className = 'meta-muted';
|
note.className = 'meta-muted';
|
||||||
note.textContent = 'Загрузка связей...';
|
note.textContent = 'Загрузка связей...';
|
||||||
|
|
||||||
|
const legend = document.createElement('div');
|
||||||
|
legend.className = 'network-legend';
|
||||||
|
legend.innerHTML = `
|
||||||
|
<span><i class="legend-line friend"></i> Друзья</span>
|
||||||
|
<span><i class="legend-line relative"></i> Родственники</span>
|
||||||
|
<span><i class="legend-arrow"></i> Односторонняя связь</span>
|
||||||
|
`;
|
||||||
|
|
||||||
let activeMenu = null;
|
let activeMenu = null;
|
||||||
let centerLogin = state.session.login || '';
|
let centerLogin = state.session.login || '';
|
||||||
|
let redrawEdges = () => {};
|
||||||
|
let loadSeq = 0;
|
||||||
|
|
||||||
function closeNodeMenu() {
|
function closeNodeMenu() {
|
||||||
if (!activeMenu) return;
|
if (!activeMenu) return;
|
||||||
@ -51,7 +397,7 @@ export function render({ navigate }) {
|
|||||||
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
|
menu.style.top = `${Math.max(8, Math.min(y, boardRect.height - 58))}px`;
|
||||||
|
|
||||||
const btn = menu.querySelector('button');
|
const btn = menu.querySelector('button');
|
||||||
btn.addEventListener('click', () => {
|
btn?.addEventListener('click', () => {
|
||||||
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
|
navigate(`user-profile-view/${encodeURIComponent(login)}/network-view`);
|
||||||
closeNodeMenu();
|
closeNodeMenu();
|
||||||
});
|
});
|
||||||
@ -67,10 +413,9 @@ export function render({ navigate }) {
|
|||||||
let longPressTriggered = false;
|
let longPressTriggered = false;
|
||||||
|
|
||||||
const clearTimer = () => {
|
const clearTimer = () => {
|
||||||
if (timerId) {
|
if (!timerId) return;
|
||||||
window.clearTimeout(timerId);
|
window.clearTimeout(timerId);
|
||||||
timerId = 0;
|
timerId = 0;
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
node.addEventListener('pointerdown', (event) => {
|
node.addEventListener('pointerdown', (event) => {
|
||||||
@ -95,7 +440,6 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
node.addEventListener('pointerleave', clearTimer);
|
node.addEventListener('pointerleave', clearTimer);
|
||||||
node.addEventListener('pointercancel', clearTimer);
|
node.addEventListener('pointercancel', clearTimer);
|
||||||
|
|
||||||
node.addEventListener('pointerup', (event) => {
|
node.addEventListener('pointerup', (event) => {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
clearTimer();
|
clearTimer();
|
||||||
@ -105,54 +449,46 @@ export function render({ navigate }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(nextCenterLogin = '') {
|
async function load(nextCenterLogin = '') {
|
||||||
const targetCenter = nextCenterLogin || centerLogin || state.session.login;
|
const requestId = ++loadSeq;
|
||||||
|
const targetCenter = normalizeLogin(nextCenterLogin || centerLogin || state.session.login);
|
||||||
centerLogin = targetCenter;
|
centerLogin = targetCenter;
|
||||||
closeNodeMenu();
|
closeNodeMenu();
|
||||||
note.textContent = 'Загрузка связей...';
|
note.textContent = 'Загрузка связей...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
const graph = await authService.getUserConnectionsGraph(targetCenter);
|
||||||
|
if (requestId !== loadSeq) return;
|
||||||
|
|
||||||
|
const model = buildGraphModel(graph, targetCenter);
|
||||||
|
const layout = layoutNodes(model);
|
||||||
|
|
||||||
board.innerHTML = '';
|
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');
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
svg.setAttribute('class', 'network-svg');
|
svg.setAttribute('class', 'network-svg');
|
||||||
svg.setAttribute('viewBox', '0 0 100 100');
|
board.append(svg);
|
||||||
svg.setAttribute('preserveAspectRatio', 'none');
|
|
||||||
|
|
||||||
left.forEach((name, i) => svg.append(mk(name, 'left', i, left.length)));
|
const nodeElements = new Map();
|
||||||
right.forEach((name, i) => svg.append(mk(name, 'right', i, right.length)));
|
layout.nodes.forEach((nodeModel) => {
|
||||||
board.prepend(svg);
|
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 = 'Тап по узлу: информация о пользователе. Долгое нажатие: центрировать граф.';
|
redrawEdges = () => renderEdges(svg, board, nodeElements, layout.edges);
|
||||||
} catch (e) {
|
requestAnimationFrame(() => redrawEdges());
|
||||||
note.textContent = `Ошибка загрузки связей: ${e.message || 'unknown'}`;
|
|
||||||
|
note.textContent = 'Тап: информация о пользователе. Долгое нажатие: сделать узел центром. Линия = взаимно, стрелка = в одну сторону.';
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== loadSeq) return;
|
||||||
|
note.textContent = `Ошибка загрузки связей: ${error?.message || 'unknown'}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,8 +499,20 @@ export function render({ navigate }) {
|
|||||||
closeNodeMenu();
|
closeNodeMenu();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointerdown', outsideTapHandler, true);
|
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 = () => {
|
screen.cleanup = () => {
|
||||||
document.removeEventListener('pointerdown', outsideTapHandler, true);
|
document.removeEventListener('pointerdown', outsideTapHandler, true);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
if (observer) observer.disconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
board.addEventListener('pointerdown', (event) => {
|
board.addEventListener('pointerdown', (event) => {
|
||||||
@ -176,7 +524,6 @@ export function render({ navigate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
screen.append(renderHeader({ title: 'Связи' }), legend, board, note);
|
||||||
screen.append(renderHeader({ title: 'Связи' }), board, note);
|
|
||||||
return screen;
|
return screen;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import { profile } from '../mock-data.js';
|
import { profile } from '../mock-data.js';
|
||||||
import { state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
import {
|
import {
|
||||||
PROFILE_GENDER_FEMALE,
|
PROFILE_GENDER_FEMALE,
|
||||||
PROFILE_GENDER_MALE,
|
PROFILE_GENDER_MALE,
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
saveProfileParamBlock,
|
saveProfileParamBlock,
|
||||||
saveProfileToggle,
|
saveProfileToggle,
|
||||||
} from '../services/user-profile-params.js';
|
} 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: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@ -36,6 +36,40 @@ const GENDER_OPTIONS = Object.freeze([
|
|||||||
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
|
{ 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) {
|
function escapeHtml(text) {
|
||||||
return String(text || '')
|
return String(text || '')
|
||||||
.replaceAll('&', '&')
|
.replaceAll('&', '&')
|
||||||
@ -90,9 +124,21 @@ export function render({ navigate }) {
|
|||||||
const listWrap = document.createElement('div');
|
const listWrap = document.createElement('div');
|
||||||
listWrap.className = 'stack profile-param-list';
|
listWrap.className = 'stack profile-param-list';
|
||||||
|
|
||||||
|
const relativesCard = document.createElement('div');
|
||||||
|
relativesCard.className = 'card stack';
|
||||||
|
relativesCard.innerHTML = `
|
||||||
|
<div class="profile-param-value"><b>Близкие родственники</b></div>
|
||||||
|
<div class="meta-muted">
|
||||||
|
Добавьте связь: родитель, ребёнок, брат/сестра или близкий друг.
|
||||||
|
Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя.
|
||||||
|
</div>
|
||||||
|
<button class="secondary-btn" type="button" data-add-relative="true">Добавить близких родственников</button>
|
||||||
|
`;
|
||||||
|
|
||||||
const reloadBtn = topRow.querySelector('[data-reload="true"]');
|
const reloadBtn = topRow.querySelector('[data-reload="true"]');
|
||||||
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
||||||
|
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
|
||||||
|
|
||||||
let currentFields = [];
|
let currentFields = [];
|
||||||
let currentToggles = [];
|
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 = `
|
||||||
|
<div class="modal" id="profile-add-relative-modal">
|
||||||
|
<div class="modal-card stack">
|
||||||
|
<h3 class="modal-title">Добавить связь</h3>
|
||||||
|
<label class="meta-muted" for="profile-relative-kind">Какой тип связи вы хотите добавить?</label>
|
||||||
|
<select class="input profile-relation-select" id="profile-relative-kind">
|
||||||
|
${RELATIVE_RELATION_OPTIONS.map((item) => (
|
||||||
|
`<option value="${item.value}">${item.label}</option>`
|
||||||
|
)).join('')}
|
||||||
|
</select>
|
||||||
|
<label class="meta-muted" for="profile-relative-login">Логин пользователя</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
id="profile-relative-login"
|
||||||
|
type="text"
|
||||||
|
maxlength="30"
|
||||||
|
placeholder="Введите логин пользователя"
|
||||||
|
list="${datalistId}"
|
||||||
|
/>
|
||||||
|
<datalist id="${datalistId}">
|
||||||
|
${uniqueContacts.map((contact) => `<option value="${escapeHtml(contact)}"></option>`).join('')}
|
||||||
|
</datalist>
|
||||||
|
<div class="meta-muted inline-error" id="profile-relative-error"></div>
|
||||||
|
<div class="form-actions-grid">
|
||||||
|
<button class="secondary-btn" id="profile-relative-cancel" type="button">Отмена</button>
|
||||||
|
<button class="primary-btn" id="profile-relative-submit" type="button">Продолжить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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) {
|
function renderFields(fields) {
|
||||||
listWrap.innerHTML = '';
|
listWrap.innerHTML = '';
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
@ -280,6 +418,7 @@ export function render({ navigate }) {
|
|||||||
reloadBtn.disabled = true;
|
reloadBtn.disabled = true;
|
||||||
officialBtn.disabled = true;
|
officialBtn.disabled = true;
|
||||||
shineBtn.disabled = true;
|
shineBtn.disabled = true;
|
||||||
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
|
||||||
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
|
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
|
||||||
if (genderActionBtn instanceof HTMLButtonElement) {
|
if (genderActionBtn instanceof HTMLButtonElement) {
|
||||||
genderActionBtn.disabled = true;
|
genderActionBtn.disabled = true;
|
||||||
@ -306,6 +445,7 @@ export function render({ navigate }) {
|
|||||||
reloadBtn.disabled = false;
|
reloadBtn.disabled = false;
|
||||||
officialBtn.disabled = false;
|
officialBtn.disabled = false;
|
||||||
shineBtn.disabled = false;
|
shineBtn.disabled = false;
|
||||||
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
|
||||||
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
|
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
|
||||||
if (genderActionBtnAfter instanceof HTMLButtonElement) {
|
if (genderActionBtnAfter instanceof HTMLButtonElement) {
|
||||||
genderActionBtnAfter.disabled = false;
|
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) => {
|
listWrap.addEventListener('click', (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLElement)) return;
|
if (!(target instanceof HTMLElement)) return;
|
||||||
@ -393,8 +615,9 @@ export function render({ navigate }) {
|
|||||||
reloadBtn.addEventListener('click', refreshProfileSnapshot);
|
reloadBtn.addEventListener('click', refreshProfileSnapshot);
|
||||||
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
||||||
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
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);
|
screen.append(card);
|
||||||
|
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|||||||
@ -48,6 +48,9 @@ const CONNECTION_SUBTYPES = Object.freeze({
|
|||||||
friend: { on: 10, off: 11 },
|
friend: { on: 10, off: 11 },
|
||||||
contact: { on: 20, off: 21 },
|
contact: { on: 20, off: 21 },
|
||||||
follow: { on: 30, off: 31 },
|
follow: { on: 30, off: 31 },
|
||||||
|
parent: { on: 50, off: 51 },
|
||||||
|
child: { on: 52, off: 53 },
|
||||||
|
sibling: { on: 54, off: 55 },
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeServerUrl(url) {
|
function normalizeServerUrl(url) {
|
||||||
|
|||||||
@ -68,6 +68,12 @@ async function buildRelationsModel(login) {
|
|||||||
inContacts: [],
|
inContacts: [],
|
||||||
outFollows: [],
|
outFollows: [],
|
||||||
inFollows: [],
|
inFollows: [],
|
||||||
|
outParents: [],
|
||||||
|
inParents: [],
|
||||||
|
outChildren: [],
|
||||||
|
inChildren: [],
|
||||||
|
outSiblings: [],
|
||||||
|
inSiblings: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,6 +110,12 @@ async function buildRelationsModel(login) {
|
|||||||
inContacts: readArray(graph, 'inContacts') || [],
|
inContacts: readArray(graph, 'inContacts') || [],
|
||||||
outFollows,
|
outFollows,
|
||||||
inFollows: readArray(graph, 'inFollows') || [],
|
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: [],
|
inContacts: [],
|
||||||
outFollows: [],
|
outFollows: [],
|
||||||
inFollows: [],
|
inFollows: [],
|
||||||
|
outParents: [],
|
||||||
|
inParents: [],
|
||||||
|
outChildren: [],
|
||||||
|
inChildren: [],
|
||||||
|
outSiblings: [],
|
||||||
|
inSiblings: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return buildRelationsModel(login);
|
return buildRelationsModel(login);
|
||||||
@ -157,6 +175,12 @@ export function relationFlagsForTarget(relations, targetLogin) {
|
|||||||
inContact: listContainsLogin(relations?.inContacts, targetLogin),
|
inContact: listContainsLogin(relations?.inContacts, targetLogin),
|
||||||
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
|
outFollow: listContainsLogin(relations?.outFollows, targetLogin),
|
||||||
inFollow: listContainsLogin(relations?.inFollows, 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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -253,7 +253,8 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-gender-select {
|
.profile-gender-select,
|
||||||
|
.profile-relation-select {
|
||||||
min-height: 46px;
|
min-height: 46px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(157, 185, 238, 0.35);
|
border: 1px solid rgba(157, 185, 238, 0.35);
|
||||||
@ -264,7 +265,8 @@
|
|||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-gender-select:focus {
|
.profile-gender-select:focus,
|
||||||
|
.profile-relation-select:focus {
|
||||||
border-color: rgba(120, 211, 255, 0.9);
|
border-color: rgba(120, 211, 255, 0.9);
|
||||||
box-shadow: 0 0 0 3px rgba(65, 174, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(65, 174, 255, 0.2);
|
||||||
}
|
}
|
||||||
@ -846,16 +848,74 @@ textarea.input {
|
|||||||
overflow: hidden;
|
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 {
|
.network-svg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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 {
|
.node {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
transform: translate(-50%, -50%);
|
transform: translate(-50%, -50%);
|
||||||
width: 74px;
|
width: 90px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-dot {
|
.node-dot {
|
||||||
@ -866,8 +926,18 @@ textarea.input {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
background: #2f4265;
|
background: #2b3f66;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
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 {
|
.node.center .node-dot {
|
||||||
@ -880,6 +950,13 @@ textarea.input {
|
|||||||
.node-label {
|
.node-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #d6e2ff;
|
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 {
|
.node-menu {
|
||||||
|
|||||||
@ -88,6 +88,21 @@ public final class MsgSubType {
|
|||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
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) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import java.util.Objects;
|
|||||||
* FRIEND=10, UNFRIEND=11
|
* FRIEND=10, UNFRIEND=11
|
||||||
* CONTACT=20, UNCONTACT=21
|
* CONTACT=20, UNCONTACT=21
|
||||||
* FOLLOW=30, UNFOLLOW=31
|
* FOLLOW=30, UNFOLLOW=31
|
||||||
|
* PARENT=50, UNPARENT=51
|
||||||
|
* CHILD=52, UNCHILD=53
|
||||||
|
* SIBLING=54, UNSIBLING=55
|
||||||
*
|
*
|
||||||
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
* bodyBytes (BigEndian), новый формат (toLogin НЕ ХРАНИМ):
|
||||||
* [4] lineCode
|
* [4] lineCode
|
||||||
@ -181,7 +184,13 @@ public final class ConnectionBody implements BodyRecord, BodyHasTarget, BodyHasL
|
|||||||
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_CONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
|| v == (MsgSubType.CONNECTION_UNCONTACT & 0xFFFF)
|
||||||
|| v == (MsgSubType.CONNECTION_FOLLOW & 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
|
@Override
|
||||||
|
|||||||
@ -48,6 +48,15 @@ public final class DatabaseInitializer {
|
|||||||
public static final short CONNECTION_FOLLOW = 30;
|
public static final short CONNECTION_FOLLOW = 30;
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
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) {
|
public static void createNewDB(String[] args) {
|
||||||
AppConfig config = AppConfig.getInstance();
|
AppConfig config = AppConfig.getInstance();
|
||||||
String dbPath = config.getParam("db.path");
|
String dbPath = config.getParam("db.path");
|
||||||
|
|||||||
@ -194,17 +194,23 @@ public final class DatabaseTriggersInstaller {
|
|||||||
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
|
int FRIEND = (int) DatabaseInitializer.CONNECTION_FRIEND;
|
||||||
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
|
int CONTACT = (int) DatabaseInitializer.CONNECTION_CONTACT;
|
||||||
int FOLLOW = (int) DatabaseInitializer.CONNECTION_FOLLOW;
|
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 UNFRIEND = (int) DatabaseInitializer.CONNECTION_UNFRIEND;
|
||||||
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
int UNCONTACT = (int) DatabaseInitializer.CONNECTION_UNCONTACT;
|
||||||
int UNFOLLOW = (int) DatabaseInitializer.CONNECTION_UNFOLLOW;
|
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("""
|
st.executeUpdate("""
|
||||||
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
CREATE TRIGGER IF NOT EXISTS trg_blocks_connection_state_ai
|
||||||
AFTER INSERT ON blocks
|
AFTER INSERT ON blocks
|
||||||
WHEN NEW.msg_type = 3
|
WHEN NEW.msg_type = 3
|
||||||
BEGIN
|
BEGIN
|
||||||
-- FRIEND/CONTACT/FOLLOW:
|
-- FRIEND/CONTACT/FOLLOW/PARENT/CHILD/SIBLING:
|
||||||
-- 1) если записи нет — создаём
|
-- 1) если записи нет — создаём
|
||||||
INSERT OR IGNORE INTO connections_state (
|
INSERT OR IGNORE INTO connections_state (
|
||||||
login, rel_type, to_login, to_bch_name, to_block_number, to_block_hash
|
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_bch_name,
|
||||||
NEW.to_block_number,
|
NEW.to_block_number,
|
||||||
NEW.to_block_hash
|
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(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
CASE
|
CASE
|
||||||
@ -256,7 +262,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
AND NEW.msg_sub_type IN (%d, %d)
|
AND NEW.msg_sub_type IN (%d, %d, %d, %d, %d)
|
||||||
AND COALESCE(
|
AND COALESCE(
|
||||||
NEW.to_login,
|
NEW.to_login,
|
||||||
CASE
|
CASE
|
||||||
@ -269,7 +275,7 @@ public final class DatabaseTriggersInstaller {
|
|||||||
) IS NOT NULL
|
) IS NOT NULL
|
||||||
AND NEW.to_bch_name IS NOT NULL;
|
AND NEW.to_bch_name IS NOT NULL;
|
||||||
|
|
||||||
-- UNFRIEND/UNCONTACT/UNFOLLOW:
|
-- UNFRIEND/UNCONTACT/UNFOLLOW/UNPARENT/UNCHILD/UNSIBLING:
|
||||||
-- удаляем соответствующее "позитивное" состояние
|
-- удаляем соответствующее "позитивное" состояние
|
||||||
DELETE FROM connections_state
|
DELETE FROM connections_state
|
||||||
WHERE login = NEW.login
|
WHERE login = NEW.login
|
||||||
@ -284,6 +290,9 @@ public final class DatabaseTriggersInstaller {
|
|||||||
END
|
END
|
||||||
)
|
)
|
||||||
AND rel_type = CASE NEW.msg_sub_type
|
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
|
WHEN %d THEN %d
|
||||||
WHEN %d THEN %d
|
WHEN %d THEN %d
|
||||||
@ -299,17 +308,20 @@ public final class DatabaseTriggersInstaller {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
) IS NOT NULL
|
) 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;
|
END;
|
||||||
""".formatted(
|
""".formatted(
|
||||||
FRIEND, CONTACT, FOLLOW,
|
FRIEND, CONTACT, FOLLOW, PARENT, CHILD, SIBLING,
|
||||||
FRIEND, CONTACT,
|
FRIEND, CONTACT, PARENT, CHILD, SIBLING,
|
||||||
|
|
||||||
UNFRIEND, FRIEND,
|
UNFRIEND, FRIEND,
|
||||||
UNCONTACT, CONTACT,
|
UNCONTACT, CONTACT,
|
||||||
UNFOLLOW, FOLLOW,
|
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));
|
""".formatted(EDIT_POST, EDIT_REPLY));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,8 +40,8 @@ public final class MsgSubType {
|
|||||||
/* ===================== CONNECTION (msg_type=3) ===================== */
|
/* ===================== CONNECTION (msg_type=3) ===================== */
|
||||||
/**
|
/**
|
||||||
* Совпадает с ConnectionBody:
|
* Совпадает с ConnectionBody:
|
||||||
* SET: FRIEND=10, CONTACT=20, FOLLOW=30
|
* SET: FRIEND=10, CONTACT=20, FOLLOW=30, PARENT=50, CHILD=52, SIBLING=54
|
||||||
* UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31
|
* UNSET: UNFRIEND=11, UNCONTACT=21, UNFOLLOW=31, UNPARENT=51, UNCHILD=53, UNSIBLING=55
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Добавить в друзья. */
|
/** Добавить в друзья. */
|
||||||
@ -62,6 +62,24 @@ public final class MsgSubType {
|
|||||||
/** Отписаться (unfollow). */
|
/** Отписаться (unfollow). */
|
||||||
public static final short CONNECTION_UNFOLLOW = 31;
|
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) ===================== */
|
/* ===================== USER_PARAM (msg_type=4) ===================== */
|
||||||
|
|
||||||
/** Параметр профиля key/value (обе строки). */
|
/** Параметр профиля key/value (обе строки). */
|
||||||
|
|||||||
@ -14,7 +14,13 @@ import shine.db.dao.ConnectionsStateDAO;
|
|||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.ResultSet;
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
||||||
@Override
|
@Override
|
||||||
@ -37,6 +43,22 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
List<String> inContacts = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CONTACT);
|
||||||
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
List<String> outFollows = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||||
List<String> inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
List<String> inFollows = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_FOLLOW);
|
||||||
|
List<String> outParents = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_PARENT);
|
||||||
|
List<String> inParents = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_PARENT);
|
||||||
|
List<String> outChildren = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||||
|
List<String> inChildren = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_CHILD);
|
||||||
|
List<String> outSiblings = ConnectionsStateDAO.getInstance().listOutgoingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||||
|
List<String> inSiblings = ConnectionsStateDAO.getInstance().listIncomingByRelTypeCanonical(c, canonicalLogin, MsgSubType.CONNECTION_SIBLING);
|
||||||
|
|
||||||
|
LinkedHashSet<String> allLogins = new LinkedHashSet<>();
|
||||||
|
allLogins.add(canonicalLogin);
|
||||||
|
addAllLogins(allLogins, outFriends, inFriends, outContacts, inContacts, outFollows, inFollows,
|
||||||
|
outParents, inParents, outChildren, inChildren, outSiblings, inSiblings);
|
||||||
|
|
||||||
|
Map<String, UserMeta> metaByLogin = loadUserMeta(c, allLogins);
|
||||||
|
List<String> parentLogins = mergeUnique(outParents, inChildren);
|
||||||
|
List<String> childLogins = mergeUnique(outChildren, inParents);
|
||||||
|
List<String> siblingLogins = mergeUnique(outSiblings, inSiblings);
|
||||||
|
|
||||||
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
|
Net_GetUserConnectionsGraph_Response resp = new Net_GetUserConnectionsGraph_Response();
|
||||||
resp.setOp(req.getOp());
|
resp.setOp(req.getOp());
|
||||||
@ -49,6 +71,16 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
resp.setInContacts(inContacts);
|
resp.setInContacts(inContacts);
|
||||||
resp.setOutFollows(outFollows);
|
resp.setOutFollows(outFollows);
|
||||||
resp.setInFollows(inFollows);
|
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;
|
return resp;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,4 +94,150 @@ public class Net_GetUserConnectionsGraph_Handler implements JsonMessageHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SafeVarargs
|
||||||
|
private final void addAllLogins(Set<String> target, List<String>... lists) {
|
||||||
|
if (target == null || lists == null) return;
|
||||||
|
for (List<String> list : lists) {
|
||||||
|
if (list == null) continue;
|
||||||
|
for (String login : list) {
|
||||||
|
String clean = (login == null) ? "" : login.trim();
|
||||||
|
if (!clean.isEmpty()) target.add(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> mergeUnique(List<String> first, List<String> second) {
|
||||||
|
LinkedHashMap<String, String> byKey = new LinkedHashMap<>();
|
||||||
|
addToMergeMap(byKey, first);
|
||||||
|
addToMergeMap(byKey, second);
|
||||||
|
return new ArrayList<>(byKey.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addToMergeMap(Map<String, String> target, List<String> 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<String, UserMeta> loadUserMeta(Connection c, Set<String> logins) throws Exception {
|
||||||
|
Map<String, UserMeta> 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<Net_GetUserConnectionsGraph_Response.RelativeItem> toRelativeItems(
|
||||||
|
List<String> logins,
|
||||||
|
Map<String, UserMeta> metaByLogin
|
||||||
|
) {
|
||||||
|
List<Net_GetUserConnectionsGraph_Response.RelativeItem> 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<Net_GetUserConnectionsGraph_Response.UserMarkItem> toUserMarkItems(
|
||||||
|
Set<String> logins,
|
||||||
|
Map<String, UserMeta> metaByLogin
|
||||||
|
) {
|
||||||
|
List<Net_GetUserConnectionsGraph_Response.UserMarkItem> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,48 @@ public class Net_GetUserConnectionsGraph_Response extends Net_Response {
|
|||||||
private List<String> inContacts = new ArrayList<>();
|
private List<String> inContacts = new ArrayList<>();
|
||||||
private List<String> outFollows = new ArrayList<>();
|
private List<String> outFollows = new ArrayList<>();
|
||||||
private List<String> inFollows = new ArrayList<>();
|
private List<String> inFollows = new ArrayList<>();
|
||||||
|
private List<String> outParents = new ArrayList<>();
|
||||||
|
private List<String> inParents = new ArrayList<>();
|
||||||
|
private List<String> outChildren = new ArrayList<>();
|
||||||
|
private List<String> inChildren = new ArrayList<>();
|
||||||
|
private List<String> outSiblings = new ArrayList<>();
|
||||||
|
private List<String> inSiblings = new ArrayList<>();
|
||||||
|
private List<RelativeItem> parents = new ArrayList<>();
|
||||||
|
private List<RelativeItem> children = new ArrayList<>();
|
||||||
|
private List<RelativeItem> siblings = new ArrayList<>();
|
||||||
|
private List<UserMarkItem> 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 String getLogin() { return login; }
|
||||||
public void setLogin(String login) { this.login = 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<String> outFollows) { this.outFollows = outFollows; }
|
public void setOutFollows(List<String> outFollows) { this.outFollows = outFollows; }
|
||||||
public List<String> getInFollows() { return inFollows; }
|
public List<String> getInFollows() { return inFollows; }
|
||||||
public void setInFollows(List<String> inFollows) { this.inFollows = inFollows; }
|
public void setInFollows(List<String> inFollows) { this.inFollows = inFollows; }
|
||||||
|
public List<String> getOutParents() { return outParents; }
|
||||||
|
public void setOutParents(List<String> outParents) { this.outParents = outParents; }
|
||||||
|
public List<String> getInParents() { return inParents; }
|
||||||
|
public void setInParents(List<String> inParents) { this.inParents = inParents; }
|
||||||
|
public List<String> getOutChildren() { return outChildren; }
|
||||||
|
public void setOutChildren(List<String> outChildren) { this.outChildren = outChildren; }
|
||||||
|
public List<String> getInChildren() { return inChildren; }
|
||||||
|
public void setInChildren(List<String> inChildren) { this.inChildren = inChildren; }
|
||||||
|
public List<String> getOutSiblings() { return outSiblings; }
|
||||||
|
public void setOutSiblings(List<String> outSiblings) { this.outSiblings = outSiblings; }
|
||||||
|
public List<String> getInSiblings() { return inSiblings; }
|
||||||
|
public void setInSiblings(List<String> inSiblings) { this.inSiblings = inSiblings; }
|
||||||
|
public List<RelativeItem> getParents() { return parents; }
|
||||||
|
public void setParents(List<RelativeItem> parents) { this.parents = parents; }
|
||||||
|
public List<RelativeItem> getChildren() { return children; }
|
||||||
|
public void setChildren(List<RelativeItem> children) { this.children = children; }
|
||||||
|
public List<RelativeItem> getSiblings() { return siblings; }
|
||||||
|
public void setSiblings(List<RelativeItem> siblings) { this.siblings = siblings; }
|
||||||
|
public List<UserMarkItem> getAllUsers() { return allUsers; }
|
||||||
|
public void setAllUsers(List<UserMarkItem> allUsers) { this.allUsers = allUsers; }
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user