509 lines
20 KiB
JavaScript
509 lines
20 KiB
JavaScript
import { renderHeader } from '../components/header.js';
|
||
import { authService, state } from '../state.js';
|
||
import {
|
||
buildIdentityLines,
|
||
loadRelationsForPair,
|
||
loadUserProfileCard,
|
||
} from '../services/user-connections.js';
|
||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||
|
||
import { navigateBack } from '../router.js';
|
||
|
||
export const pageMeta = { id: 'user', title: 'Чужой профиль' };
|
||
|
||
function escapeHtml(text) {
|
||
return String(text || '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function openProfileInfoModal({ title, text }) {
|
||
const root = document.getElementById('modal-root');
|
||
if (!root) return;
|
||
root.innerHTML = `
|
||
<div class="modal" id="profile-info-modal">
|
||
<div class="modal-card stack">
|
||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||
<button class="secondary-btn" type="button" id="profile-info-close">Закрыть</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
const close = () => { root.innerHTML = ''; };
|
||
root.querySelector('#profile-info-close')?.addEventListener('click', close);
|
||
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||
if (event.target?.id === 'profile-info-modal') close();
|
||
});
|
||
}
|
||
|
||
function officialInfoText() {
|
||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||
+ 'Но для корректного учёта голосов на одного реального человека используется только один официальный канал.';
|
||
}
|
||
|
||
function shineInfoText() {
|
||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||
+ 'Пять принципов сияющих:\n'
|
||
+ '1) сияющие не обманывают;\n'
|
||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||
}
|
||
|
||
function genderText(value) {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
if (normalized === 'male') return 'Мужской';
|
||
if (normalized === 'female') return 'Женский';
|
||
return 'Не указан';
|
||
}
|
||
|
||
function relationButtonLabel(kind, flags) {
|
||
if (kind === 'contact') return flags.outContact ? 'Убрать из контактов' : 'Добавить в контакты';
|
||
if (kind === 'friend') return flags.outFriend ? 'Убрать из близких друзей' : 'Добавить в близкие друзья';
|
||
return flags.outFollow ? 'Отписаться' : 'Подписаться';
|
||
}
|
||
|
||
function relationNextState(kind, flags) {
|
||
if (kind === 'contact') return !flags.outContact;
|
||
if (kind === 'friend') return !flags.outFriend;
|
||
return !flags.outFollow;
|
||
}
|
||
|
||
function relationConfirmLabel(kind) {
|
||
if (kind === 'contact') return 'контакт';
|
||
if (kind === 'friend') return 'статус близкого друга';
|
||
return 'подписку';
|
||
}
|
||
|
||
function relationStateText(kind, flags) {
|
||
if (kind === 'contact') {
|
||
if (flags.outContact && flags.inContact) return 'Вы обменялись контактами.';
|
||
if (flags.outContact) return 'Вы добавили этот профиль в контакты.';
|
||
if (flags.inContact) return 'Этот профиль добавил вас в контакты.';
|
||
return '';
|
||
}
|
||
if (kind === 'friend') {
|
||
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 'Этот профиль подписан на вас.';
|
||
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 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;
|
||
}
|
||
|
||
function renderIdentity(card) {
|
||
const lines = buildIdentityLines({
|
||
login: card.login,
|
||
firstName: card.firstName,
|
||
lastName: card.lastName,
|
||
});
|
||
|
||
const row = document.createElement('div');
|
||
row.className = 'row';
|
||
row.style.gap = '12px';
|
||
row.style.alignItems = 'center';
|
||
|
||
row.append(renderUserAvatar({
|
||
login: card.login,
|
||
firstName: card.firstName,
|
||
lastName: card.lastName,
|
||
avatar: card.avatar,
|
||
size: 'large',
|
||
className: 'profile-avatar',
|
||
}));
|
||
|
||
const identityLines = document.createElement('div');
|
||
identityLines.className = 'profile-identity-lines';
|
||
lines.forEach((line, idx) => {
|
||
const lineEl = document.createElement('div');
|
||
lineEl.className = `profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}`;
|
||
lineEl.textContent = line;
|
||
identityLines.append(lineEl);
|
||
});
|
||
row.append(identityLines);
|
||
|
||
return row;
|
||
}
|
||
|
||
function renderReadOnlyBadges(card) {
|
||
return `
|
||
<div class="row wrap-row">
|
||
<button class="badge profile-badge-trigger ${card.official ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">Официальный: ${card.official ? 'Yes' : 'No'}</button>
|
||
<button class="badge profile-badge-trigger ${card.shine ? 'is-yes-shine' : 'is-no'}" type="button" data-profile-info="shine">Сияющий: ${card.shine ? 'Yes' : 'No'}</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 },
|
||
{ label: 'Фамилия', value: card.lastName },
|
||
{ label: 'Пол', value: genderText(card.gender) },
|
||
{ label: 'Адрес', value: card.address },
|
||
{ label: 'Web', value: card.web },
|
||
{ label: 'Телефон', value: card.phone },
|
||
];
|
||
|
||
return `
|
||
<div class="card stack profile-param-list">
|
||
${rows.map((row) => `
|
||
<div class="card profile-param-item row">
|
||
<div class="profile-param-value"><b>${row.label}</b>: ${escapeHtml(String(row.value || '').trim() || 'не заполнено')}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
export function render({ navigate, route }) {
|
||
const requestedLogin = String(route.params.login || '').trim();
|
||
const sessionLogin = String(state.session.login || '').trim();
|
||
|
||
const screen = document.createElement('section');
|
||
screen.className = 'stack';
|
||
|
||
const status = document.createElement('div');
|
||
status.className = 'status-line';
|
||
status.textContent = 'Загрузка профиля...';
|
||
|
||
const body = document.createElement('div');
|
||
body.className = 'stack';
|
||
|
||
screen.append(
|
||
renderHeader({
|
||
title: 'Профиль пользователя',
|
||
leftAction: { label: '←', onClick: () => navigateBack() },
|
||
rightActions: [{ label: 'Показать\nсвязи', onClick: () => navigate(makeProfileLinksRoute(requestedLogin || '')) }],
|
||
}),
|
||
status,
|
||
body,
|
||
);
|
||
const linksHeaderBtn = screen.querySelector('.header-actions .icon-btn');
|
||
linksHeaderBtn?.classList.add('profile-links-header-btn');
|
||
|
||
let currentCard = null;
|
||
let currentFlags = null;
|
||
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 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);
|
||
}
|
||
|
||
async function refresh() {
|
||
if (!requestedLogin) {
|
||
status.className = 'status-line is-unavailable';
|
||
status.textContent = 'Не передан login пользователя.';
|
||
return;
|
||
}
|
||
|
||
isBusy = true;
|
||
status.className = 'status-line';
|
||
status.textContent = 'Загрузка профиля...';
|
||
|
||
try {
|
||
const card = await loadUserProfileCard(requestedLogin);
|
||
const flags = await loadRelationsForPair({
|
||
currentLogin: sessionLogin,
|
||
targetLogin: card.login,
|
||
});
|
||
|
||
currentCard = card;
|
||
currentFlags = flags;
|
||
|
||
body.innerHTML = `
|
||
${renderReadOnlyBadges(card)}
|
||
${renderRelations(flags)}
|
||
${renderReadOnlyParams(card)}
|
||
`;
|
||
const identityCard = document.createElement('div');
|
||
identityCard.className = 'card stack';
|
||
identityCard.append(renderIdentity(card));
|
||
body.prepend(identityCard);
|
||
|
||
syncActionButtons();
|
||
if (String(route?.params?.section || '').toLowerCase() === 'links') {
|
||
const rel = body.querySelector('[data-profile-relations="true"]');
|
||
rel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
status.className = 'status-line is-available';
|
||
status.textContent = 'Профиль обновлён.';
|
||
} catch (error) {
|
||
status.className = 'status-line is-unavailable';
|
||
status.textContent = `Ошибка загрузки профиля: ${error.message || 'unknown'}`;
|
||
window.alert(`Не удалось загрузить профиль: ${error.message || 'unknown'}`);
|
||
} finally {
|
||
isBusy = false;
|
||
}
|
||
}
|
||
|
||
async function onRelationAction(kind) {
|
||
if (isBusy || !currentCard || !currentFlags) return;
|
||
if (!sessionLogin) {
|
||
window.alert('Для изменения связей нужен активный вход.');
|
||
return;
|
||
}
|
||
if (!state.session.storagePwdInMemory) {
|
||
window.alert('Нет storagePwd в памяти сессии. Выполните вход заново.');
|
||
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` +
|
||
'Будет отправлен AddBlock CONNECTION.',
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
isBusy = true;
|
||
status.className = 'status-line';
|
||
status.textContent = 'Сохранение отношения в блокчейн...';
|
||
|
||
try {
|
||
await authService.setUserRelation({
|
||
login: sessionLogin,
|
||
toLogin: currentCard.login,
|
||
kind,
|
||
enabled: nextEnabled,
|
||
storagePwd: state.session.storagePwdInMemory,
|
||
});
|
||
await refresh();
|
||
} catch (error) {
|
||
status.className = 'status-line is-unavailable';
|
||
status.textContent = `Ошибка изменения связи: ${error.message || 'unknown'}`;
|
||
window.alert(`Не удалось изменить связь: ${error.message || 'unknown'}`);
|
||
isBusy = false;
|
||
}
|
||
}
|
||
|
||
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;
|
||
const infoBtn = target.closest('[data-profile-info]');
|
||
const infoKind = String(infoBtn?.getAttribute('data-profile-info') || '');
|
||
if (infoKind === 'official') {
|
||
openProfileInfoModal({
|
||
title: 'Официальный канал',
|
||
text: officialInfoText(),
|
||
});
|
||
return;
|
||
}
|
||
if (infoKind === 'shine') {
|
||
openProfileInfoModal({
|
||
title: 'Справка о сияющих',
|
||
text: shineInfoText(),
|
||
});
|
||
return;
|
||
}
|
||
const actionBtn = target.closest('[data-relation-action]');
|
||
const kind = String(actionBtn?.getAttribute('data-relation-action') || '');
|
||
if (!kind) return;
|
||
void onRelationAction(kind);
|
||
});
|
||
|
||
refresh();
|
||
return screen;
|
||
}
|