SHA256
Обновить UI сети и профиль
This commit is contained in:
+136
-249
@@ -1,27 +1,15 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { authService, state } from '../state.js';
|
||||
import {
|
||||
PROFILE_GENDER_FEMALE,
|
||||
PROFILE_GENDER_MALE,
|
||||
loadProfileSnapshot,
|
||||
} from '../services/user-profile-params.js';
|
||||
import { buildIdentityLines } from '../services/user-connections.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createDropdownMenu } from '../components/dropdown-menu.js';
|
||||
import { userDisplayName } from '../services/user-display.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
function toggleText(enabled) {
|
||||
return enabled ? 'Yes' : 'No';
|
||||
}
|
||||
|
||||
function genderLabel(value) {
|
||||
if (value === PROFILE_GENDER_MALE) return 'Мужской';
|
||||
if (value === PROFILE_GENDER_FEMALE) return 'Женский';
|
||||
return 'Не указан';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -31,72 +19,57 @@ function escapeHtml(text) {
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function renderProfileInfoText(text) {
|
||||
const [intro, details] = String(text || '').split(/\n\n/, 2);
|
||||
if (!details) {
|
||||
return `<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>`;
|
||||
}
|
||||
|
||||
const [sectionTitle, ...items] = details.split('\n').filter(Boolean);
|
||||
return `
|
||||
<p class="profile-info-modal__lead">${escapeHtml(intro)}</p>
|
||||
<section class="profile-info-modal__section" aria-label="${escapeHtml(sectionTitle)}">
|
||||
<p class="profile-info-modal__section-title">${escapeHtml(sectionTitle)}</p>
|
||||
<ol class="profile-info-modal__list">
|
||||
${items.map((item) => `<li>${escapeHtml(item.replace(/^\d+\)\s*/, ''))}</li>`).join('')}
|
||||
</ol>
|
||||
</section>
|
||||
`;
|
||||
function fieldMap(snapshot) {
|
||||
const out = {};
|
||||
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
|
||||
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function openProfileInfoModal({ title, text }) {
|
||||
function openTextModal(title, text) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div class="modal profile-info-modal" id="profile-info-modal" role="dialog" aria-modal="true" aria-labelledby="profile-info-title">
|
||||
<section class="modal-card profile-info-modal__card stack">
|
||||
<header class="profile-info-modal__header">
|
||||
<div>
|
||||
<p class="profile-info-modal__eyebrow">Информация</p>
|
||||
<h3 class="modal-title profile-info-modal__title" id="profile-info-title">${escapeHtml(title)}</h3>
|
||||
</div>
|
||||
<button class="profile-info-modal__close" type="button" id="profile-info-close-icon" aria-label="Закрыть" title="Закрыть">×</button>
|
||||
</header>
|
||||
<div class="profile-info-modal__content">
|
||||
${renderProfileInfoText(text)}
|
||||
</div>
|
||||
<footer class="profile-info-modal__footer">
|
||||
<button class="secondary-btn profile-info-modal__confirm" type="button" id="profile-info-close">Готово</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
<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-info-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-info-close-icon')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-info-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-info-modal') close();
|
||||
root.querySelector('#profile-text-close')?.addEventListener('click', close);
|
||||
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'profile-text-modal') close();
|
||||
});
|
||||
}
|
||||
|
||||
function officialInfoText() {
|
||||
return 'Можно создавать несколько альтернативных или анонимных каналов. '
|
||||
+ 'Но для корректного учёта голосов на одного реального человека используется только один основной аккаунт.';
|
||||
function statusBadges(accountRole, shineStatus) {
|
||||
const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
|
||||
const shine = shineStatus === 'shining' ? 'Сияющий' : '';
|
||||
return `<div class="row wrap-row">
|
||||
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
|
||||
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
|
||||
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function shineInfoText() {
|
||||
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
|
||||
+ 'Пять принципов сияющих:\n'
|
||||
+ '1) сияющие не обманывают;\n'
|
||||
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
|
||||
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
|
||||
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
|
||||
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
|
||||
function statsRows(stats = {}) {
|
||||
return [
|
||||
['friends', 'Друзья', stats.friendsCount],
|
||||
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
|
||||
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
|
||||
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
|
||||
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
|
||||
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
|
||||
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
|
||||
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
|
||||
];
|
||||
}
|
||||
|
||||
export function render({ navigate, chrome }) {
|
||||
const login = state.session.login || profile.login;
|
||||
|
||||
const login = String(state.session.login || profile.login || '').trim();
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack profile-screen';
|
||||
|
||||
@@ -106,15 +79,12 @@ export function render({ navigate, chrome }) {
|
||||
<div class="header-left" aria-hidden="true"></div>
|
||||
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
|
||||
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false">
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const profileMenuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
profileMenuButton?.append(createOverflowDots());
|
||||
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
|
||||
</div>`;
|
||||
const menuButton = topbar.querySelector('.profile-head-menu-btn');
|
||||
menuButton?.append(createOverflowDots());
|
||||
const profileMenu = createDropdownMenu({
|
||||
anchorEl: profileMenuButton,
|
||||
anchorEl: menuButton,
|
||||
className: 'profile-head-menu',
|
||||
minWidth: 230,
|
||||
items: [
|
||||
@@ -123,197 +93,114 @@ export function render({ navigate, chrome }) {
|
||||
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
|
||||
],
|
||||
});
|
||||
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack profile-main-card';
|
||||
const status = document.createElement('div');
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Загрузка профиля...';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'stack';
|
||||
screen.append(status, body);
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row';
|
||||
topRow.innerHTML = `
|
||||
<div class="row" style="gap:12px; align-items:center;">
|
||||
<div data-profile-avatar-slot="true"></div>
|
||||
<div class="profile-identity-lines" data-profile-identity="true">
|
||||
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
let current = null;
|
||||
|
||||
const badgesRow = document.createElement('div');
|
||||
badgesRow.className = 'row';
|
||||
badgesRow.innerHTML = `
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
|
||||
`;
|
||||
function renderProfile() {
|
||||
if (!current) return;
|
||||
const { snapshot, user } = current;
|
||||
const fields = fieldMap(snapshot);
|
||||
const firstName = fields.first_name || '';
|
||||
const lastName = fields.last_name || '';
|
||||
const displayName = userDisplayName({ login, firstName, lastName });
|
||||
const avatar = snapshot?.avatar?.txId
|
||||
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null;
|
||||
const stats = {
|
||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||
friendsCount: Number(user?.friendsCount || 0),
|
||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
|
||||
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
|
||||
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
|
||||
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
|
||||
};
|
||||
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'stack profile-param-list';
|
||||
body.innerHTML = '';
|
||||
const identity = document.createElement('div');
|
||||
identity.className = 'card row';
|
||||
identity.style.gap = '12px';
|
||||
identity.style.alignItems = 'center';
|
||||
identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
|
||||
const identityText = document.createElement('div');
|
||||
identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
|
||||
identity.append(identityText);
|
||||
body.append(identity);
|
||||
|
||||
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]');
|
||||
const shineBtn = badgesRow.querySelector('[data-status="shine"]');
|
||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
||||
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
|
||||
const badges = document.createElement('div');
|
||||
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
|
||||
body.append(...badges.children);
|
||||
|
||||
let currentFields = [];
|
||||
let currentAccountRole = '';
|
||||
let currentShineStatus = '';
|
||||
let currentGender = 'unknown';
|
||||
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
let currentStats = {
|
||||
ownedPublicChannelsCount: 0,
|
||||
followingUsersCount: 0,
|
||||
followingChannelsCount: 0,
|
||||
closeFriendsCount: 0,
|
||||
};
|
||||
|
||||
function syncIdentity() {
|
||||
if (!identityEl) return;
|
||||
const firstName = currentFields.find((field) => field.key === 'first_name')?.value || '';
|
||||
const lastName = currentFields.find((field) => field.key === 'last_name')?.value || '';
|
||||
const lines = buildIdentityLines({ login, firstName, lastName });
|
||||
identityEl.innerHTML = lines.map((line, idx) => (
|
||||
`<div class="profile-identity-line${idx === lines.length - 1 ? ' profile-identity-login' : ''}">${escapeHtml(line)}</div>`
|
||||
)).join('');
|
||||
}
|
||||
|
||||
function updateAvatarUi() {
|
||||
if (!(avatarSlotEl instanceof HTMLElement)) return;
|
||||
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
|
||||
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
|
||||
avatarSlotEl.innerHTML = '';
|
||||
avatarSlotEl.append(renderUserAvatar({
|
||||
login,
|
||||
firstName,
|
||||
lastName,
|
||||
avatar: currentAvatar?.txId
|
||||
? { ar: currentAvatar.txId, sha256Hex: String(currentAvatar?.sha256Hex || '').trim().toLowerCase() }
|
||||
: null,
|
||||
size: 'xl',
|
||||
className: 'profile-avatar',
|
||||
}));
|
||||
}
|
||||
|
||||
function updateStatusesUi() {
|
||||
if (accountRoleBtn) {
|
||||
const label = currentAccountRole === 'primary' ? 'Основной аккаунт' : currentAccountRole === 'non_voting' ? 'Не учитывать мой голос' : 'Не указано';
|
||||
accountRoleBtn.textContent = `Аккаунт: ${label}`;
|
||||
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||
accountRoleBtn.classList.add(currentAccountRole === 'primary' ? 'is-yes-official' : 'is-no');
|
||||
}
|
||||
if (shineBtn) {
|
||||
const label = currentShineStatus === 'shining' ? 'Сияющий' : currentShineStatus === 'not_interested' ? 'Сияние неинтересно' : currentShineStatus === 'unknown' ? 'Неизвестно' : 'Не указано';
|
||||
shineBtn.textContent = `Сияние: ${label}`;
|
||||
shineBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||
shineBtn.classList.add(currentShineStatus === 'shining' ? 'is-yes-shine' : currentShineStatus === 'not_interested' ? 'is-not-interested' : 'is-no');
|
||||
if (fields.about) {
|
||||
const about = document.createElement('div');
|
||||
about.className = 'card profile-about';
|
||||
about.style.whiteSpace = 'pre-wrap';
|
||||
about.textContent = fields.about;
|
||||
body.append(about);
|
||||
}
|
||||
|
||||
const statsGrid = document.createElement('div');
|
||||
statsGrid.className = 'profile-stats-grid';
|
||||
statsRows(stats).forEach(([kind, label, value]) => {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'card profile-stat-card';
|
||||
button.dataset.profileList = kind;
|
||||
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
|
||||
statsGrid.append(button);
|
||||
});
|
||||
body.append(statsGrid);
|
||||
|
||||
const detailRow = document.createElement('div');
|
||||
detailRow.className = 'row wrap-row';
|
||||
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
|
||||
body.append(detailRow);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const stats = [
|
||||
{ label: 'Собственные публичные каналы', value: currentStats.ownedPublicChannelsCount },
|
||||
{ label: 'Подписки на пользователей', value: currentStats.followingUsersCount },
|
||||
{ label: 'Подписки на каналы', value: currentStats.followingChannelsCount },
|
||||
{ label: 'Близкие друзья', value: currentStats.closeFriendsCount },
|
||||
];
|
||||
stats.forEach((stat) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
row.innerHTML = `<div class="profile-param-value"><b>${escapeHtml(stat.label)}</b>: ${escapeHtml(String(Number(stat.value || 0)))}</div>`;
|
||||
listWrap.append(row);
|
||||
});
|
||||
}
|
||||
|
||||
accountRoleBtn?.classList.add('profile-badge-trigger');
|
||||
shineBtn?.classList.add('profile-badge-trigger');
|
||||
accountRoleBtn?.addEventListener('click', () => {
|
||||
openProfileInfoModal({
|
||||
title: 'Основной аккаунт',
|
||||
text: officialInfoText(),
|
||||
});
|
||||
});
|
||||
shineBtn?.addEventListener('click', () => {
|
||||
openProfileInfoModal({
|
||||
title: 'Справка о сияющих',
|
||||
text: shineInfoText(),
|
||||
});
|
||||
});
|
||||
|
||||
function renderFields(fields) {
|
||||
listWrap.innerHTML = '';
|
||||
renderStats();
|
||||
fields.forEach((field) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
const value = String(field.value || '').trim() || 'не заполнено';
|
||||
row.innerHTML = `<div class="profile-param-value"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
|
||||
listWrap.append(row);
|
||||
|
||||
if (field.key === 'last_name') {
|
||||
const genderRow = document.createElement('div');
|
||||
genderRow.className = 'card profile-param-item row';
|
||||
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: ${escapeHtml(genderLabel(currentGender))}</div>`;
|
||||
listWrap.append(genderRow);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshProfileSnapshot() {
|
||||
if (state.session.isLocalDemo) {
|
||||
currentFields = [
|
||||
{ key: 'first_name', label: 'Имя', value: 'Тестовый' },
|
||||
{ key: 'last_name', label: 'Фамилия', value: 'Пользователь' },
|
||||
{ key: 'address', label: 'Адрес', value: 'Локальный режим' },
|
||||
{ key: 'website', label: 'Веб', value: '127.0.0.1' },
|
||||
{ key: 'phone', label: 'Телефон', value: profile.phone },
|
||||
];
|
||||
currentAccountRole = '';
|
||||
currentShineStatus = '';
|
||||
currentGender = 'unknown';
|
||||
currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
currentStats = {
|
||||
ownedPublicChannelsCount: 0,
|
||||
followingUsersCount: 0,
|
||||
followingChannelsCount: 0,
|
||||
closeFriendsCount: 0,
|
||||
};
|
||||
syncIdentity();
|
||||
updateAvatarUi();
|
||||
updateStatusesUi();
|
||||
renderFields(currentFields);
|
||||
body.addEventListener('click', (event) => {
|
||||
if (!current) return;
|
||||
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
|
||||
if (!el) return;
|
||||
const kind = el.dataset.profileList;
|
||||
if (kind) {
|
||||
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [snapshot, user] = await Promise.all([
|
||||
loadProfileSnapshot(login),
|
||||
authService.getUser(login).catch(() => ({})),
|
||||
]);
|
||||
currentFields = Array.isArray(snapshot.fields) ? snapshot.fields : [];
|
||||
currentAccountRole = snapshot.accountRole || '';
|
||||
currentShineStatus = snapshot.shineStatus || '';
|
||||
currentGender = snapshot.gender || 'unknown';
|
||||
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
|
||||
currentStats = {
|
||||
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
|
||||
followingUsersCount: Number(user?.followingUsersCount || 0),
|
||||
followingChannelsCount: Number(user?.followingChannelsCount || 0),
|
||||
closeFriendsCount: Number(user?.closeFriendsCount || 0),
|
||||
};
|
||||
syncIdentity();
|
||||
updateAvatarUi();
|
||||
updateStatusesUi();
|
||||
renderFields(currentFields);
|
||||
} catch (error) {
|
||||
// ignore status row in profile-view
|
||||
const fields = fieldMap(current.snapshot);
|
||||
if (el.dataset.profileDetail === 'contacts') {
|
||||
openTextModal('Контакты', [
|
||||
fields.web ? `Links: ${fields.web}` : '',
|
||||
fields.phone ? `Телефон: ${fields.phone}` : '',
|
||||
fields.address ? `Адрес: ${fields.address}` : '',
|
||||
].filter(Boolean).join('\n') || 'Не заполнено');
|
||||
}
|
||||
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (state.session.isLocalDemo) {
|
||||
status.textContent = 'Локальный тестовый режим.';
|
||||
return;
|
||||
}
|
||||
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
|
||||
current = { snapshot, user };
|
||||
renderProfile();
|
||||
status.textContent = '';
|
||||
}
|
||||
|
||||
card.append(topRow, badgesRow, listWrap);
|
||||
screen.append(card);
|
||||
|
||||
updateAvatarUi();
|
||||
refreshProfileSnapshot();
|
||||
refresh().catch((error) => {
|
||||
status.className = 'status-line is-unavailable';
|
||||
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
|
||||
});
|
||||
|
||||
screen.cleanup = () => profileMenu.destroy();
|
||||
return screen;
|
||||
|
||||
Reference in New Issue
Block a user