Добавлены родственные связи, расширен граф связей и улучшен локальный запуск

Что добавлено:\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:
AidarKC
2026-04-17 21:01:53 +03:00
parent 9b188d56e9
commit 4a92a7fa22
13 changed files with 1061 additions and 73 deletions
+400 -53
View File
@@ -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 = `<div class="node-dot">${(name[0] || '?').toUpperCase()}</div><div class="node-label">${name}</div>`;
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 = `
<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 }) {
@@ -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 = `
<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 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;
}
+226 -3
View File
@@ -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('&', '&amp;')
@@ -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 = `
<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 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 = `
<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) {
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();
+3
View File
@@ -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) {
+24
View File
@@ -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),
};
}