Files
SHiNE-server/shine-UI/js/pages/profile-view.js
T

321 lines
14 KiB
JavaScript

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';
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('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 openProfileInfoModal({ 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="Закрыть">&times;</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>
`;
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();
});
}
function officialInfoText() {
return 'Можно создавать несколько альтернативных или анонимных каналов. '
+ 'Но для корректного учёта голосов на одного реального человека используется только один основной аккаунт.';
}
function shineInfoText() {
return 'Сияющие — это те, от кого идёт внутреннее сияние на тонком плане.\n\n'
+ 'Пять принципов сияющих:\n'
+ '1) сияющие не обманывают;\n'
+ '2) сияющие чувствуют, что человек — это не только физическое тело, а нечто большее;\n'
+ '3) сияющие развиваются и в духовной, и в материальной плоскости;\n'
+ '4) у сияющих есть близкие друзья, с которыми им по-настоящему хорошо;\n'
+ '5) сияющие заботятся о мире: о людях, гармонии и общем благе.';
}
export function render({ navigate, chrome }) {
const login = state.session.login || profile.login;
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = `
<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());
const profileMenu = createDropdownMenu({
anchorEl: profileMenuButton,
className: 'profile-head-menu',
minWidth: 230,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ 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 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>
`;
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>
`;
const listWrap = document.createElement('div');
listWrap.className = 'stack profile-param-list';
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"]');
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');
}
}
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);
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
}
}
card.append(topRow, badgesRow, listWrap);
screen.append(card);
updateAvatarUi();
refreshProfileSnapshot();
screen.cleanup = () => profileMenu.destroy();
return screen;
}