Доделать связи и профиль

This commit is contained in:
AidarKC
2026-09-01 14:59:42 +04:00
parent a66d7a64e6
commit 390a2b988e
16 changed files with 599 additions and 304 deletions
+19 -52
View File
@@ -104,10 +104,8 @@ function applyRelativeGender(map, rows) {
function getRelativeGenderMap(graph) {
const map = new Map();
applyRelativeGender(map, graph?.parents);
applyRelativeGender(map, graph?.children);
applyRelativeGender(map, graph?.siblings);
applyRelativeGender(map, graph?.spouses);
// Родственные связи пока скрыты из UI, хотя сервер продолжает хранить их коды.
void graph;
return map;
}
@@ -115,6 +113,8 @@ function buildGraphModel(graph, centerLogin) {
const login = normalizeLogin(graph?.login || centerLogin || state.session.login);
const outFriends = toSet(graph?.outFriends);
const inFriends = toSet(graph?.inFriends);
const outCloseFriends = toSet(graph?.outCloseFriends);
const inCloseFriends = toSet(graph?.inCloseFriends);
const outParents = toSet(graph?.outParents);
const inParents = toSet(graph?.inParents);
const outChildren = toSet(graph?.outChildren);
@@ -128,8 +128,8 @@ function buildGraphModel(graph, centerLogin) {
const inContacts = toSet(graph?.inContacts);
const outFollows = toSet(graph?.outFollows);
const inFollows = toSet(graph?.inFollows);
const outKnown = toSet(graph?.outKnownPersons);
const inKnown = toSet(graph?.inKnownPersons);
const outOfficial = toSet(graph?.outOfficialAccounts);
const inOfficial = toSet(graph?.inOfficialAccounts);
const relativesGender = getRelativeGenderMap(graph);
const allMarks = getMarkByLogin(graph?.allUsers);
@@ -137,67 +137,35 @@ function buildGraphModel(graph, centerLogin) {
const allLogins = uniqueLogins([
...(graph?.outFriends || []),
...(graph?.inFriends || []),
...(graph?.outParents || []),
...(graph?.inParents || []),
...(graph?.outChildren || []),
...(graph?.inChildren || []),
...(graph?.outSiblings || []),
...(graph?.inSiblings || []),
...(graph?.outSpouses || []),
...(graph?.inSpouses || []),
...(graph?.outCloseFriends || []),
...(graph?.inCloseFriends || []),
...(graph?.outContacts || []),
...(graph?.inContacts || []),
...(graph?.outFollows || []),
...(graph?.inFollows || []),
...(graph?.outKnownPersons || []),
...(graph?.inKnownPersons || []),
...(graph?.outOfficialAccounts || []),
...(graph?.inOfficialAccounts || []),
]).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 spouseOut = hasLogin(outSpouses, targetLogin);
const spouseIn = hasLogin(inSpouses, targetLogin);
const friendOut = hasLogin(outFriends, targetLogin);
const friendIn = hasLogin(inFriends, targetLogin);
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outKnown, targetLogin);
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inKnown, targetLogin);
const closeFriendOut = hasLogin(outCloseFriends, targetLogin);
const closeFriendIn = hasLogin(inCloseFriends, targetLogin);
const contactOut = hasLogin(outContacts, targetLogin) || hasLogin(outFollows, targetLogin) || hasLogin(outOfficial, targetLogin);
const contactIn = hasLogin(inContacts, targetLogin) || hasLogin(inFollows, targetLogin) || hasLogin(inOfficial, targetLogin);
let role = 'contact';
if (parentOut || parentIn) role = 'parent';
else if (childOut || childIn) role = 'child';
else if (spouseOut || spouseIn) role = 'spouse';
else if (siblingOut || siblingIn) role = 'sibling';
else if (friendOut || friendIn) role = 'friend';
if (closeFriendOut || closeFriendIn || friendOut || friendIn) role = 'friend';
let forward = friendOut;
let backward = friendIn;
if (role === 'parent') {
forward = parentOut;
backward = parentIn;
} else if (role === 'child') {
forward = childOut;
backward = childIn;
} else if (role === 'spouse') {
forward = spouseOut;
backward = spouseIn;
} else if (role === 'sibling') {
forward = siblingOut;
backward = siblingIn;
} else if (role === 'contact') {
forward = contactOut;
backward = contactIn;
}
let forward = role === 'friend' ? (closeFriendOut || friendOut) : contactOut;
let backward = role === 'friend' ? (closeFriendIn || friendIn) : contactIn;
return {
login: targetLogin,
key: normKey(targetLogin),
role,
isRelative: role === 'parent' || role === 'child' || role === 'spouse' || role === 'sibling',
isRelative: false,
gender: normalizeGender(relativesGender.get(normKey(targetLogin))),
forward: Boolean(forward),
backward: Boolean(backward),
@@ -242,11 +210,10 @@ export function render({ navigate, route, chrome } = {}) {
// Фильтры слоёв (Фаза 3). Фокус всегда виден; предикат применяется к периферийным узлам.
const FILTERS = {
all: { label: 'Все', pred: () => true },
family: { label: 'Семья', pred: (n) => n.relationType === 'family' },
friends: { label: 'Друзья', pred: (n) => n.relationType === 'friend' },
shining: { label: 'Сияющие', pred: (n) => Boolean(n.shining) },
};
const FILTER_ORDER = ['all', 'family', 'friends', 'shining'];
const FILTER_ORDER = ['all', 'friends', 'shining'];
let activeFilter = 'all';
const filterChips = {};
+17 -22
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { renderHeader } from '../components/header.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
@@ -38,11 +38,9 @@ const GENDER_OPTIONS = Object.freeze([
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
]);
// Родственные типы сохранены в протоколе, но пока намеренно скрыты из UI.
const RELATIVE_RELATION_OPTIONS = Object.freeze([
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
{ value: 'spouse', label: 'Жена / Муж (по полу)' },
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
{ value: 'friend', label: 'Друг' },
{ value: 'close_friend', label: 'Близкий друг' },
]);
@@ -75,6 +73,7 @@ function relationAccusativeLabel(type, targetGender) {
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
return 'жену/мужа';
}
if (type === 'friend') return 'друга';
return 'близкого друга';
}
@@ -134,12 +133,12 @@ export function render({ navigate, chrome }) {
const relativesCard = document.createElement('div');
relativesCard.className = 'card stack';
relativesCard.innerHTML = `
<div class="profile-param-value"><b>Близкие родственники</b></div>
<div class="profile-param-value"><b>Друзья</b></div>
<div class="meta-muted">
Добавьте связь: родитель, ребёнок, жена/муж, брат/сестра или близкий друг.
Формулировка (мать/отец, брат/сестра) определяется по полу выбранного пользователя.
Добавьте пользователя в друзья или в близкие друзья.
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить близких родственников</button>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
`;
const reloadBtn = topRow.querySelector('[data-reload="true"]');
@@ -753,20 +752,16 @@ export function render({ navigate, chrome }) {
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,
});
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) {
+59 -203
View File
@@ -64,20 +64,29 @@ function genderText(value) {
function relationButtonLabel(kind, flags) {
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
return flags.outFollow ? 'Отписаться' : 'Подписаться';
if (kind === 'friend') return flags.outFriend ? 'Убрать из друзей' : 'Добавить в друзья';
if (kind === 'close_friend') return flags.outCloseFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
if (kind === 'official_account') return flags.outOfficialAccount ? 'Снять подтверждение' : 'Подтвердить аккаунт';
if (kind === 'shine_confirmed') return flags.outShineConfirmed ? 'Снять подтверждение' : 'Подтвердить сияющего';
return '';
}
function relationNextState(kind, flags) {
if (kind === 'contact') return !flags.outContact;
if (kind === 'friend') return !flags.outFriend;
return !flags.outFollow;
if (kind === 'close_friend') return !flags.outCloseFriend;
if (kind === 'official_account') return !flags.outOfficialAccount;
if (kind === 'shine_confirmed') return !flags.outShineConfirmed;
return false;
}
function relationConfirmLabel(kind) {
if (kind === 'contact') return 'контакт';
if (kind === 'friend') return 'статус близкого друга';
return 'подписку';
if (kind === 'friend') return 'статус друга';
if (kind === 'close_friend') return 'статус близкого друга';
if (kind === 'official_account') return 'подтверждение официального аккаунта';
if (kind === 'shine_confirmed') return 'подтверждение сияющего';
return 'связь';
}
function relationStateText(kind, flags) {
@@ -88,55 +97,49 @@ function relationStateText(kind, flags) {
return '';
}
if (kind === 'friend') {
if (flags.outFriend && flags.inFriend) return 'Вы взаимно близкие друзья.';
if (flags.outFriend) return 'Вы считаете этот профиль близким другом.';
if (flags.inFriend) return 'Этот профиль считает вас близким другом.';
if (flags.outFriend && flags.inFriend) return 'Вы взаимно считаете друг друга друзьями.';
if (flags.outFriend) return 'Вы считаете этого человека другом.';
if (flags.inFriend) return 'Этот человек считает вас другом.';
return '';
}
if (flags.outFollow && flags.inFollow) return 'Вы взаимно подписаны.';
if (flags.outFollow) return 'Вы подписаны на этот профиль.';
if (flags.inFollow) return 'Этот профиль подписан на вас.';
if (kind === 'close_friend') {
if (flags.outCloseFriend && flags.inCloseFriend) return 'Вы взаимно близкие друзья.';
if (flags.outCloseFriend) return 'Вы считаете этого человека близким другом.';
if (flags.inCloseFriend) return 'Этот человек считает вас близким другом.';
return '';
}
if (kind === 'official_account') {
return flags.outOfficialAccount
? 'Вы подтверждаете, что это действительно официальный аккаунт этого человека.'
: '';
}
if (kind === 'shine_confirmed') {
return flags.outShineConfirmed
? 'Вы подтверждаете, что этот человек сияющий.'
: '';
}
return '';
}
function opinionItemsFromFlags(flags) {
const items = [];
if (flags.outShineSeen) {
items.push({
kind: 'shine_seen',
text: 'вы утверждаете, что очень мало знаете этого человека, но вы видели его сияющим, и всё, что вы о нём знаете, подтверждает это',
label: 'видел сияющим',
});
}
if (flags.outShineConfirmed) {
items.push({
kind: 'shine_confirmed',
text: 'вы утверждаете, что достаточно хорошо знаете этого человека и точно уверены, что этот человек сияющий',
label: 'точно сияющий',
});
}
if (flags.outKnownPerson) {
items.push({
kind: 'known_person',
text: 'вы утверждаете, что просто знаете этого человека',
label: 'просто знаю',
});
}
return items;
}
function renderRelations(flags) {
const rows = [
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'close_friend', text: relationStateText('close_friend', flags), button: relationButtonLabel('close_friend', flags) },
{ kind: 'official_account', text: relationStateText('official_account', flags), button: relationButtonLabel('official_account', flags) },
{ kind: 'shine_confirmed', text: relationStateText('shine_confirmed', flags), button: relationButtonLabel('shine_confirmed', flags) },
];
function resolveActiveOpinionKind(flags) {
if (flags.outShineSeen) return 'shine_seen';
if (flags.outShineConfirmed) return 'shine_confirmed';
if (flags.outKnownPerson) return 'known_person';
return '';
}
function opinionLabelByKind(kind) {
if (kind === 'shine_seen') return 'мало знаком, но видел сияющим';
if (kind === 'shine_confirmed') return 'точно уверен, что сияющий';
if (kind === 'known_person') return 'просто знаю человека';
return kind;
return `
<div class="card stack user-relations-list" data-profile-relations="true">
${rows.map((row) => `
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
<span class="user-rel-text">${escapeHtml(row.text)}</span>
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
</div>
`).join('')}
</div>
`;
}
function renderIdentity(card) {
@@ -182,82 +185,6 @@ function renderReadOnlyBadges(card) {
`;
}
function renderRelations(flags) {
const rows = [
{ kind: 'contact', text: relationStateText('contact', flags), button: relationButtonLabel('contact', flags) },
{ kind: 'friend', text: relationStateText('friend', flags), button: relationButtonLabel('friend', flags) },
{ kind: 'follow', text: relationStateText('follow', flags), button: relationButtonLabel('follow', flags) },
];
const opinionItems = opinionItemsFromFlags(flags);
const hasOpinion = opinionItems.length > 0;
return `
<div class="card stack user-relations-list" data-profile-relations="true">
${rows.map((row) => `
<div class="user-rel-row ${row.text ? '' : 'is-empty'}">
<span class="user-rel-text">${escapeHtml(row.text)}</span>
<button class="ghost-btn user-rel-action" type="button" data-relation-action="${row.kind}">${escapeHtml(row.button)}</button>
</div>
`).join('')}
<div class="user-rel-opinions-wrap ${hasOpinion ? '' : 'is-empty'}">
<div class="user-rel-opinions-list">
${opinionItems.map((item) => `
<div class="user-rel-opinion-item">${escapeHtml(item.text)}</div>
`).join('')}
</div>
<div class="user-rel-opinions-hint">Добавьте одну из этих трёх формулировок.</div>
</div>
<div class="user-rel-row">
<span class="user-rel-text">${hasOpinion ? 'Мнение уже добавлено.' : 'Пока нет дополнительной связи.'}</span>
<button class="ghost-btn user-rel-action user-rel-opinion-btn" type="button" data-relation-action="opinion-menu">${hasOpinion ? 'Изменить мнение' : 'Добавить мнение'}</button>
</div>
</div>
`;
}
function openOpinionMenuModal({ flags, onApply }) {
const root = document.getElementById('modal-root');
if (!root) return;
const activeKind = resolveActiveOpinionKind(flags);
const items = [
{ kind: 'known_person', title: 'просто знаю человека' },
{ kind: 'shine_confirmed', title: 'точно уверен, что сияющий' },
{ kind: 'shine_seen', title: 'мало знаком, но видел сияющим' },
];
const rowsHtml = items
.filter((item) => item.kind !== activeKind)
.map((item) => `<button class="secondary-btn user-opinion-modal-btn is-add" type="button" data-opinion-kind="${item.kind}" data-opinion-mode="set">Высказать: ${item.title}</button>`)
.join('');
const removeHtml = activeKind
? `<button class="secondary-btn user-opinion-modal-btn is-remove" type="button" data-opinion-kind="${activeKind}" data-opinion-mode="remove">Убрать мнение</button>`
: '';
root.innerHTML = `
<div class="modal" id="user-opinion-modal">
<div class="modal-card stack">
<h3 class="modal-title">${activeKind ? 'Изменить мнение' : 'Добавить мнение'}</h3>
<div class="stack">${rowsHtml}${removeHtml}</div>
<button class="secondary-btn" type="button" id="user-opinion-modal-close">Закрыть</button>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#user-opinion-modal-close')?.addEventListener('click', close);
root.querySelector('#user-opinion-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'user-opinion-modal') close();
});
root.querySelectorAll('[data-opinion-mode]').forEach((btn) => {
btn.addEventListener('click', async () => {
const nextKind = String(btn.getAttribute('data-opinion-kind') || '').trim();
const mode = String(btn.getAttribute('data-opinion-mode') || '').trim();
close();
if (!nextKind) return;
await onApply({ mode, nextKind, activeKind });
});
});
}
function renderReadOnlyParams(card) {
const rows = [
{ label: 'Имя', value: card.firstName },
@@ -310,20 +237,15 @@ export function render({ navigate, route }) {
let isBusy = false;
function syncActionButtons() {
const followBtn = body.querySelector('[data-relation-action="follow"]');
const friendBtn = body.querySelector('[data-relation-action="friend"]');
const contactBtn = body.querySelector('[data-relation-action="contact"]');
const opinionBtn = body.querySelector('[data-relation-action="opinion-menu"]');
if (!followBtn || !friendBtn || !contactBtn || !opinionBtn || !currentFlags) return;
const kinds = ['contact', 'friend', 'close_friend', 'official_account', 'shine_confirmed'];
if (!currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
contactBtn.textContent = relationButtonLabel('contact', currentFlags);
friendBtn.textContent = relationButtonLabel('friend', currentFlags);
followBtn.textContent = relationButtonLabel('follow', currentFlags);
contactBtn.disabled = Boolean(isSelf);
friendBtn.disabled = Boolean(isSelf);
followBtn.disabled = Boolean(isSelf);
opinionBtn.textContent = opinionItemsFromFlags(currentFlags).length ? 'Изменить мнение' : 'Добавить мнение';
opinionBtn.disabled = Boolean(isSelf);
kinds.forEach((kind) => {
const btn = body.querySelector(`[data-relation-action="${kind}"]`);
if (!btn) return;
btn.textContent = relationButtonLabel(kind, currentFlags);
btn.disabled = Boolean(isSelf);
});
}
async function refresh() {
@@ -384,14 +306,6 @@ export function render({ navigate, route }) {
return;
}
if (kind === 'opinion-menu') {
openOpinionMenuModal({
flags: currentFlags,
onApply: onOpinionApply,
});
return;
}
const nextEnabled = relationNextState(kind, currentFlags);
const confirmed = window.confirm(
`Изменить ${relationConfirmLabel(kind)} с пользователем ${currentCard.login}?\n` +
@@ -420,64 +334,6 @@ export function render({ navigate, route }) {
}
}
async function onOpinionApply({ mode, nextKind, activeKind }) {
if (isBusy || !currentCard || !currentFlags) return;
if (!sessionLogin) {
window.alert('Для изменения связей нужен активный вход.');
return;
}
if (!state.session.storagePwdInMemory) {
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
return;
}
const confirmed = window.confirm(`Изменить мнение о пользователе ${currentCard.login}?`);
if (!confirmed) return;
isBusy = true;
status.className = 'status-line';
status.textContent = 'Сохранение отношения в блокчейн...';
try {
if (activeKind) {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: activeKind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
if (mode === 'set') {
await authService.setUserRelation({
login: sessionLogin,
toLogin: currentCard.login,
kind: nextKind,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
}
await refresh();
if (mode === 'set') {
const opinionVisible = Boolean(
currentFlags?.outKnownPerson
|| currentFlags?.outShineConfirmed
|| currentFlags?.outShineSeen,
);
if (!opinionVisible) {
await new Promise((resolve) => window.setTimeout(resolve, 350));
await refresh();
}
}
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
isBusy = false;
}
}
body.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;