Доделать профиль, связи и серверные миграции

This commit is contained in:
AidarKC
2026-09-01 18:14:55 +04:00
parent 632d56737b
commit c03d94105c
42 changed files with 1555 additions and 414 deletions
+43 -358
View File
@@ -1,371 +1,56 @@
import { renderHeader } from '../components/header.js';
import { authService, state } from '../state.js';
import {
buildIdentityLines,
loadRelationsForPair,
loadUserProfileCard,
} from '../services/user-connections.js';
import { state } from '../state.js';
import { 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: 'Чужой профиль' };
export const pageMeta = { id: 'user', title: 'Профиль пользователя' };
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
function escapeHtml(text){return String(text||'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;');}
function fullName(card){return [card.firstName,card.lastName].filter(Boolean).join(' ')||card.login;}
function openTextModal(title,text){
const root=document.getElementById('modal-root'); if(!root)return;
root.innerHTML=`<div class="modal" id="profile-text-modal"><div class="modal-card stack"><h3>${escapeHtml(title)}</h3><div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text||'Не заполнено')}</div><button class="secondary-btn" id="profile-text-close">Закрыть</button></div></div>`;
const close=()=>{root.innerHTML='';}; root.querySelector('#profile-text-close')?.addEventListener('click',close); root.querySelector('#profile-text-modal')?.addEventListener('click',e=>{if(e.target?.id==='profile-text-modal')close();});
}
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 statusBadges(card){
const role=card.accountRole==='primary'?'Основной аккаунт':card.accountRole==='non_voting'?'Голос не учитывать':'';
const shine=card.shineStatus==='shining'?'Сияющий':'';
return `<div class="row wrap-row">${role?`<span class="badge">${escapeHtml(role)}</span>`:''}${shine?'<span class="badge is-yes-shine">Сияющий</span>':''}${card.shineStatus==='not_interested'?'<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>':''}</div>`;
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один основной аккаунт.';
function statsHtml(card){
const s=card.stats||{};
const rows=[
['friends','Друзья',s.friendsCount],['close_friends','Близкие друзья',s.closeFriendsCount],
['primary_received','Подтвердили основной аккаунт',s.primaryReceivedCount],['primary_given','Подтверждённые аккаунты',s.primaryGivenCount],
['shine_received','Считают сияющим',s.shineReceivedCount],['shine_given','Подтверждённые сияющие',s.shineGivenCount],
['channels_following','Подписки на каналы',s.followingChannelsCount],['channels_owned','Каналы',s.ownedPublicChannelsCount],
];
return `<div class="profile-stats-grid">${rows.map(([kind,label,n])=>`<button type="button" class="card profile-stat-card" data-profile-list="${kind}"><b>${Number(n||0)}</b><span>${escapeHtml(label)}</span></button>`).join('')}</div>`;
}
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 ? 'Убрать из друзей' : 'Добавить в друзья';
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;
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 'статус друга';
if (kind === 'close_friend') return 'статус близкого друга';
if (kind === 'official_account') return 'подтверждение официального аккаунта';
if (kind === 'shine_confirmed') 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 (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 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) },
];
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) {
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: 'xl',
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) {
const accountRole = String(card.accountRole || '').trim().toLowerCase();
const shineStatus = String(card.shineStatus || '').trim().toLowerCase();
const accountLabel = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
const shineLabel = shineStatus === 'shining' ? 'Сияющий' : '';
return `
<div class="row wrap-row">
${accountLabel ? `<button class="badge profile-badge-trigger ${accountRole === 'primary' ? 'is-yes-official' : 'is-no'}" type="button" data-profile-info="official">${escapeHtml(accountLabel)}</button>` : ''}
${shineLabel ? `<button class="badge profile-badge-trigger is-yes-shine" type="button" data-profile-info="shine">${escapeHtml(shineLabel)}</button>` : ''}
${shineStatus === 'not_interested' ? `<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно" /></span>` : ''}
</div>
`;
}
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 },
{ label: 'О себе', value: card.about },
{ label: 'Духовный путь', value: card.spiritualPath },
];
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 kinds = ['contact', 'friend', 'close_friend', 'official_account', 'shine_confirmed'];
if (!currentFlags) return;
const isSelf = currentCard && currentCard.login.toLowerCase() === sessionLogin.toLowerCase();
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() {
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;
}
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;
}
}
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;
export function render({navigate,route}){
const requestedLogin=String(route?.params?.login||'').trim(); const selfLogin=String(state.session.login||'').trim();
const screen=document.createElement('section'); screen.className='stack'; const body=document.createElement('div');body.className='stack';const status=document.createElement('div');status.className='status-line';status.textContent='Загрузка профиля...';
screen.append(renderHeader({title:'Профиль пользователя',leftAction:{label:'←',onClick:()=>navigateBack()}}),status,body);
let card=null;
async function refresh(){
card=await loadUserProfileCard(requestedLogin); const isSelf=card.login.toLowerCase()===selfLogin.toLowerCase();
body.innerHTML=`${statusBadges(card)}<div class="card profile-about" style="white-space:pre-wrap">${escapeHtml(card.about||'')}</div>${statsHtml(card)}
<div class="row wrap-row"><button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button></div>
${!isSelf?`<div class="row profile-bottom-actions"><button class="icon-btn" data-bottom="chat" title="Диалог">💬</button><button class="icon-btn" data-bottom="links" title="Связи">✦</button><button class="primary-btn" data-bottom="add">Добавить</button></div>`:''}`;
const identity=document.createElement('div');identity.className='card row';identity.style.gap='12px';identity.style.alignItems='center';
identity.append(renderUserAvatar({login:card.login,firstName:card.firstName,lastName:card.lastName,avatar:card.avatar,size:'xl',className:'profile-avatar'}));
const txt=document.createElement('div');txt.innerHTML=`<div class="profile-identity-line">${escapeHtml(fullName(card))}</div><div class="profile-identity-login">${escapeHtml(card.login)}</div>`;identity.append(txt);body.prepend(identity);status.textContent='';
}
body.addEventListener('click',e=>{const el=e.target.closest('[data-profile-list],[data-profile-detail],[data-bottom]');if(!el||!card)return;
const list=el.dataset.profileList;if(list){navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(list)}`);return;}
const detail=el.dataset.profileDetail;if(detail==='contacts'){openTextModal('Контакты',[card.web?`Links: ${card.web}`:'',card.phone?`Телефон: ${card.phone}`:'',card.address?`Адрес: ${card.address}`:''].filter(Boolean).join('\n')||'Не заполнено');return;} if(detail==='spiritual'){openTextModal('Духовный путь',card.spiritualPath);return;}
const bottom=el.dataset.bottom;if(bottom==='chat')navigate(`chat/${encodeURIComponent(card.login)}`);if(bottom==='links')navigate(makeProfileLinksRoute(card.login));if(bottom==='add')navigate(`SHiNE/${encodeURIComponent(card.login)}/manage`);
});
refresh().catch(e=>{status.className='status-line is-unavailable';status.textContent=`Ошибка: ${e.message||'unknown'}`;}); return screen;
}