@@ -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 ( '<' , '<' )
. replaceAll ( '>' , '>' )
. replaceAll ( '"' , '"' )
. replaceAll ( "'" , ''' ) ;
function escapeHtml ( text ) { return String ( text || '' ) . replaceAll ( '&' , '&' ) . replaceAll ( '<' , '<' ) . replaceAll ( '>' , '>' ) . replaceAll ( '"' , '"' ) . replaceAll ( "'" , ''' ) ; }
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 ) } < / h 3 >
< 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" > Закрыть < / b u t t o n >
< / d i v >
< / d i v >
` ;
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 ) } < / s p a n >
< button class = "ghost-btn user-rel-action" type = "button" data - relation - action = "${row.kind}" > $ { escapeHtml ( row . button ) } < / b u t t o n >
< / d i v >
` ).join('')}
< / d i v >
` ;
}
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> ` : '' }
< / d i v >
` ;
}
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 > : $ { e s c a p e H t m l ( S t r i n g ( r o w . v a l u e | | ' ' ) . t r i m ( ) | | ' н е з а п о л н е н о ' ) } < / d i v >
< / d i v >
` ).join('')}
< / d i v >
` ;
}
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" > Контакты < / b u t t o n > < b u t t o n c l a s s = " s e c o n d a r y - b t n " d a t a - p r o f i l e - d e t a i l = " s p i r i t u a l " > Д у х о в н ы й п у т ь < / b u t t o n > < / d i v >
$ { ! 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 ;
}