SHA256
Добавлены родственные связи, расширен граф связей и улучшен локальный запуск
Что добавлено:\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:
@@ -1,6 +1,6 @@
|
||||
import { renderHeader } from '../components/header.js';
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
PROFILE_GENDER_MALE,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
saveProfileParamBlock,
|
||||
saveProfileToggle,
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
@@ -36,6 +36,40 @@ const GENDER_OPTIONS = Object.freeze([
|
||||
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
|
||||
]);
|
||||
|
||||
const RELATIVE_RELATION_OPTIONS = Object.freeze([
|
||||
{ value: 'parent', label: 'Родитель (мать/отец по полу)' },
|
||||
{ value: 'child', label: 'Ребёнок (сын/дочь по полу)' },
|
||||
{ value: 'sibling', label: 'Брат или сестра (по полу)' },
|
||||
{ value: 'close_friend', label: 'Близкий друг' },
|
||||
]);
|
||||
|
||||
function normalizeGender(value) {
|
||||
const v = String(value || '').trim().toLowerCase();
|
||||
if (v === PROFILE_GENDER_MALE) return PROFILE_GENDER_MALE;
|
||||
if (v === PROFILE_GENDER_FEMALE) return PROFILE_GENDER_FEMALE;
|
||||
return PROFILE_GENDER_UNKNOWN;
|
||||
}
|
||||
|
||||
function relationAccusativeLabel(type, targetGender) {
|
||||
const gender = normalizeGender(targetGender);
|
||||
if (type === 'parent') {
|
||||
if (gender === PROFILE_GENDER_MALE) return 'отца';
|
||||
if (gender === PROFILE_GENDER_FEMALE) return 'мать';
|
||||
return 'родителя';
|
||||
}
|
||||
if (type === 'child') {
|
||||
if (gender === PROFILE_GENDER_MALE) return 'сына';
|
||||
if (gender === PROFILE_GENDER_FEMALE) return 'дочь';
|
||||
return 'ребёнка';
|
||||
}
|
||||
if (type === 'sibling') {
|
||||
if (gender === PROFILE_GENDER_MALE) return 'брата';
|
||||
if (gender === PROFILE_GENDER_FEMALE) return 'сестру';
|
||||
return 'брата/сестру';
|
||||
}
|
||||
return 'близкого друга';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -90,9 +124,21 @@ export function render({ navigate }) {
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'stack profile-param-list';
|
||||
|
||||
const relativesCard = document.createElement('div');
|
||||
relativesCard.className = 'card stack';
|
||||
relativesCard.innerHTML = `
|
||||
<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();
|
||||
|
||||
Reference in New Issue
Block a user