Files
SHiNE-server/shine-UI/js/pages/profile-edit-view.js
T
qandClaude Opus 5.5 2e62616b46 UI v4: новый стиль по макету V3/V4 и доводка экранов
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий
с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки.
Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа
со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги),
сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов –
переключатель; уведомления – список как в чатах; фото – просмотр на весь экран.
Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода,
второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов),
иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса.

Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:36:52 +03:00

852 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createTopBar } from '../components/topbar.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
PROFILE_GENDER_FEMALE,
PROFILE_GENDER_MALE,
PROFILE_GENDER_UNKNOWN,
PROFILE_ACCOUNT_ROLE_PRIMARY,
PROFILE_ACCOUNT_ROLE_NON_VOTING,
PROFILE_SHINE_SHINING,
PROFILE_SHINE_UNKNOWN,
PROFILE_SHINE_NOT_INTERESTED,
loadProfileSnapshot,
saveProfileGender,
saveProfileParamBlock,
saveProfileStatus,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
import { renderUserAvatar } from '../components/avatar-image.js';
export const pageMeta = { id: 'profile-edit-view', title: 'Редактирование профиля' };
function accountRoleLabel(value) {
if (value === PROFILE_ACCOUNT_ROLE_PRIMARY) return 'Основной аккаунт';
if (value === PROFILE_ACCOUNT_ROLE_NON_VOTING) return 'Не учитывать мой голос';
return 'Не указано';
}
function shineStatusLabel(value) {
if (value === PROFILE_SHINE_SHINING) return 'Сияющий';
if (value === PROFILE_SHINE_NOT_INTERESTED) return 'Сияние неинтересно';
if (value === PROFILE_SHINE_UNKNOWN) return 'Неизвестно';
return 'Не указано';
}
function showLocalErrorAlert(prefix, error) {
const message = error?.message || 'Неизвестная ошибка';
const stack = error?.stack ? `\n\nStack:\n${error.stack}` : '';
window.alert(`${prefix}: ${message}${stack}`);
}
function genderLabel(value) {
if (value === PROFILE_GENDER_MALE) return 'Мужской';
if (value === PROFILE_GENDER_FEMALE) return 'Женский';
return 'Не указан';
}
const GENDER_OPTIONS = Object.freeze([
{ value: PROFILE_GENDER_MALE, label: 'Мужской' },
{ value: PROFILE_GENDER_FEMALE, label: 'Женский' },
{ value: PROFILE_GENDER_UNKNOWN, label: 'Не указан' },
]);
// Родственные типы сохранены в протоколе, но пока намеренно скрыты из UI.
const RELATIVE_RELATION_OPTIONS = Object.freeze([
{ value: 'friend', label: 'Друг' },
{ value: 'close_friend', label: 'Близкий друг' },
]);
function normalizeGender(value) {
const v = String(value || '').trim().toLowerCase();
if (v === PROFILE_GENDER_MALE) return PROFILE_GENDER_MALE;
if (v === PROFILE_GENDER_FEMALE) return PROFILE_GENDER_FEMALE;
return PROFILE_GENDER_UNKNOWN;
}
function relationAccusativeLabel(type, targetGender) {
const gender = normalizeGender(targetGender);
if (type === 'parent') {
if (gender === PROFILE_GENDER_MALE) return 'отца';
if (gender === PROFILE_GENDER_FEMALE) return 'мать';
return 'родителя';
}
if (type === 'child') {
if (gender === PROFILE_GENDER_MALE) return 'сына';
if (gender === PROFILE_GENDER_FEMALE) return 'дочь';
return 'ребёнка';
}
if (type === 'sibling') {
if (gender === PROFILE_GENDER_MALE) return 'брата';
if (gender === PROFILE_GENDER_FEMALE) return 'сестру';
return 'брата/сестру';
}
if (type === 'spouse') {
if (gender === PROFILE_GENDER_MALE) return 'мужа';
if (gender === PROFILE_GENDER_FEMALE) return 'жену';
return 'жену/мужа';
}
if (type === 'friend') return 'друга';
return 'близкого друга';
}
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function render({ navigate, chrome }) {
const login = state.session.login || profile.login;
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
chrome?.setTopbar(
createTopBar({
title: 'Редактирование профиля',
back: { label: '←', onClick: () => navigate('profile-view') },
}),
);
const card = document.createElement('div');
card.className = 'stack profile-edit-card';
const topRow = document.createElement('div');
topRow.className = 'row';
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="profile-avatar-block" data-change-avatar="true" style="cursor:pointer;">
<div data-profile-avatar-slot="true"></div>
</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>
<button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
`;
const badgesRow = document.createElement('div');
badgesRow.className = 'chip-row profile-edit-statuses';
badgesRow.innerHTML = `
<button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
<button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
`;
const status = document.createElement('div');
status.className = 'status-line';
status.textContent = 'Загрузка параметров...';
const listWrap = document.createElement('div');
listWrap.className = 'nav-list profile-param-list';
const relativesCard = document.createElement('div');
relativesCard.className = 'card stack';
relativesCard.innerHTML = `
<div class="profile-param-value"><b>Друзья</b></div>
<div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
`;
const reloadBtn = topRow.querySelector('[data-reload="true"]');
const accountRoleBtn = badgesRow.querySelector('[data-status="account_role"]');
const shineBtn = badgesRow.querySelector('[data-status="shine"]');
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
const avatarActionEl = topRow.querySelector('[data-change-avatar="true"]');
let currentFields = [];
let currentAccountRole = '';
let currentShineStatus = '';
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarSlotEl = topRow.querySelector('[data-profile-avatar-slot="true"]');
function openGenderPickerModal(initialGender) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
root.innerHTML = '';
const selected = GENDER_OPTIONS.some((item) => item.value === initialGender)
? initialGender
: PROFILE_GENDER_UNKNOWN;
root.innerHTML = `
<div class="modal" id="profile-gender-modal">
<div class="modal-card stack">
<h3 class="modal-title">Выбор пола</h3>
<p class="meta-muted">Выберите значение профиля из списка:</p>
<select class="input profile-gender-select" id="profile-gender-select">
${GENDER_OPTIONS.map((item) => (
`<option value="${item.value}" ${item.value === selected ? 'selected' : ''}>${item.label}</option>`
)).join('')}
</select>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-gender-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-gender-save" type="button">Сохранить</button>
</div>
</div>
</div>
`;
return new Promise((resolve) => {
const modal = root.querySelector('#profile-gender-modal');
const selectEl = root.querySelector('#profile-gender-select');
const saveEl = root.querySelector('#profile-gender-save');
const cancelEl = root.querySelector('#profile-gender-cancel');
if (!(modal instanceof HTMLElement) || !(selectEl instanceof HTMLSelectElement)) {
root.innerHTML = '';
resolve(null);
return;
}
const close = (value = null) => {
root.innerHTML = '';
resolve(value);
};
modal.addEventListener('click', (event) => {
if (event.target === modal) close(null);
});
cancelEl?.addEventListener('click', () => close(null));
saveEl?.addEventListener('click', () => close(selectEl.value || PROFILE_GENDER_UNKNOWN));
window.setTimeout(() => selectEl.focus(), 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) {
accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
}
if (shineBtn) {
shineBtn.textContent = `Сияние: ${shineStatusLabel(currentShineStatus)}`;
shineBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
if (currentShineStatus === PROFILE_SHINE_SHINING) shineBtn.classList.add('is-yes-shine');
else if (currentShineStatus === PROFILE_SHINE_NOT_INTERESTED) shineBtn.classList.add('is-not-interested');
else shineBtn.classList.add('is-no');
}
}
function updateGenderUi() {
const genderValueEl = listWrap.querySelector('[data-gender-value]');
if (!genderValueEl) return;
genderValueEl.textContent = genderLabel(currentGender);
}
function openStatusPickerModal({ title, value, options }) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
root.innerHTML = `
<div class="modal" id="profile-status-modal">
<div class="modal-card stack">
<h3 class="modal-title">${escapeHtml(title)}</h3>
<select class="input" id="profile-status-select">
${options.map((item) => `<option value="${escapeHtml(item.value)}" ${item.value === value ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}
</select>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-status-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-status-save" type="button">Сохранить</button>
</div>
</div>
</div>`;
return new Promise((resolve) => {
const modal = root.querySelector('#profile-status-modal');
const selectEl = root.querySelector('#profile-status-select');
const close = (next = null) => { root.innerHTML = ''; resolve(next); };
modal?.addEventListener('click', (event) => { if (event.target === modal) close(null); });
root.querySelector('#profile-status-cancel')?.addEventListener('click', () => close(null));
root.querySelector('#profile-status-save')?.addEventListener('click', () => close(selectEl?.value || null));
window.setTimeout(() => selectEl?.focus(), 0);
});
}
function openFieldEditModal({ label, value, placeholder = '', maxLength = 300, multiline = false }) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
root.innerHTML = `
<div class="modal" id="profile-field-edit-modal">
<div class="modal-card stack">
<h3 class="modal-title">Изменить: ${escapeHtml(label)}</h3>
${multiline ? `<textarea
id="profile-field-edit-input"
class="input"
maxlength="${Number(maxLength || 300)}"
rows="${Number(maxLength || 300) > 1000 ? 12 : 4}"
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
>${escapeHtml(String(value || ''))}</textarea>` : `<input
id="profile-field-edit-input"
class="input"
type="text"
maxlength="${Number(maxLength || 300)}"
placeholder="${escapeHtml(placeholder || `Введите ${label.toLowerCase()}`)}"
value="${escapeHtml(String(value || ''))}"
/>`}
<div class="meta-muted">До ${Number(maxLength || 300)} символов.</div>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-field-edit-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-field-edit-save" type="button">Сохранить</button>
</div>
</div>
</div>
`;
return new Promise((resolve) => {
const modal = root.querySelector('#profile-field-edit-modal');
const inputEl = root.querySelector('#profile-field-edit-input');
const saveEl = root.querySelector('#profile-field-edit-save');
const cancelEl = root.querySelector('#profile-field-edit-cancel');
if (!(modal instanceof HTMLElement) || (!(inputEl instanceof HTMLInputElement) && !(inputEl instanceof HTMLTextAreaElement))) {
root.innerHTML = '';
resolve(null);
return;
}
const close = (nextValue = null) => {
root.innerHTML = '';
resolve(nextValue);
};
modal.addEventListener('click', (event) => {
if (event.target === modal) close(null);
});
cancelEl?.addEventListener('click', () => close(null));
saveEl?.addEventListener('click', () => close(inputEl.value));
inputEl.addEventListener('keydown', (event) => {
if (!multiline && event.key === 'Enter') {
event.preventDefault();
close(inputEl.value);
}
});
window.setTimeout(() => {
inputEl.focus();
inputEl.selectionStart = inputEl.value.length;
inputEl.selectionEnd = inputEl.value.length;
}, 0);
});
}
function openAddRelativeModal({ contacts = [] } = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(null);
const preparedContacts = Array.isArray(contacts)
? contacts
.map((item) => String(item || '').trim())
.filter(Boolean)
: [];
const uniqueContacts = Array.from(new Set(preparedContacts.map((item) => item.toLowerCase())))
.map((key) => preparedContacts.find((item) => item.toLowerCase() === key))
.filter(Boolean);
root.innerHTML = `
<div class="modal" id="profile-add-relative-modal">
<div class="modal-card stack">
<h3 class="modal-title">Добавить связь</h3>
<label class="meta-muted" for="profile-relative-kind">Какой тип связи вы хотите добавить?</label>
<select class="input profile-relation-select" id="profile-relative-kind">
${RELATIVE_RELATION_OPTIONS.map((item) => (
`<option value="${item.value}">${item.label}</option>`
)).join('')}
</select>
<label class="meta-muted" for="profile-relative-login">Логин пользователя</label>
<input
class="input"
id="profile-relative-login"
type="text"
maxlength="30"
placeholder="Введите логин пользователя"
/>
<div class="meta-muted profile-relative-search-meta" id="profile-relative-search-meta">
Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.
</div>
<div class="profile-relative-search-suggest" id="profile-relative-search-suggest" hidden></div>
<div class="meta-muted inline-error" id="profile-relative-error"></div>
<div class="form-actions-grid">
<button class="secondary-btn" id="profile-relative-cancel" type="button">Отмена</button>
<button class="primary-btn" id="profile-relative-submit" type="button">Продолжить</button>
</div>
</div>
</div>
`;
return new Promise((resolve) => {
const modal = root.querySelector('#profile-add-relative-modal');
const kindEl = root.querySelector('#profile-relative-kind');
const loginEl = root.querySelector('#profile-relative-login');
const submitEl = root.querySelector('#profile-relative-submit');
const cancelEl = root.querySelector('#profile-relative-cancel');
const errorEl = root.querySelector('#profile-relative-error');
const searchMetaEl = root.querySelector('#profile-relative-search-meta');
const suggestEl = root.querySelector('#profile-relative-search-suggest');
if (!(modal instanceof HTMLElement) || !(kindEl instanceof HTMLSelectElement) || !(loginEl instanceof HTMLInputElement)) {
root.innerHTML = '';
resolve(null);
return;
}
let closed = false;
let searchTimerId = 0;
let searchSeq = 0;
const clearSearchTimer = () => {
if (!searchTimerId) return;
window.clearTimeout(searchTimerId);
searchTimerId = 0;
};
const uniqueCaseInsensitive = (list) => {
const out = [];
const seen = new Set();
(Array.isArray(list) ? list : []).forEach((item) => {
const value = String(item || '').trim();
if (!value) return;
const key = value.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(value);
});
return out;
};
const renderSuggestions = (list) => {
if (!(suggestEl instanceof HTMLElement)) return;
const values = uniqueCaseInsensitive(list).slice(0, 5);
if (!values.length) {
suggestEl.hidden = true;
suggestEl.innerHTML = '';
return;
}
suggestEl.hidden = false;
suggestEl.innerHTML = values.map((value) => (
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
)).join('');
};
const searchContactsFallback = (prefix) => {
const query = String(prefix || '').trim().toLowerCase();
if (!query) return [];
return uniqueContacts
.filter((entry) => entry.toLowerCase().startsWith(query))
.slice(0, 5);
};
const runSearch = async ({ withErrorMessage = false } = {}) => {
const prefix = String(loginEl.value || '').trim();
if (!prefix) {
renderSuggestions([]);
if (searchMetaEl) {
searchMetaEl.textContent = 'Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.';
}
return;
}
if (searchMetaEl) searchMetaEl.textContent = `Поиск пользователей по префиксу «${prefix}»...`;
const reqId = ++searchSeq;
let found = [];
let searchFailed = false;
try {
const payload = await authService.searchUsers(prefix);
found = Array.isArray(payload) ? payload : [];
} catch {
searchFailed = true;
found = [];
}
if (closed || reqId !== searchSeq) return;
const query = prefix.toLowerCase();
const candidateList = uniqueCaseInsensitive([
...found,
...searchContactsFallback(prefix),
])
.filter((entry) => entry.toLowerCase().startsWith(query))
.slice(0, 5);
renderSuggestions(candidateList);
if (candidateList.length > 0) {
if (searchMetaEl) searchMetaEl.textContent = `Найдено ${candidateList.length} пользователей. Выберите пользователя из списка.`;
return;
}
if (searchFailed) {
if (searchMetaEl) searchMetaEl.textContent = 'Не удалось получить список пользователей. Уточните логин вручную.';
if (withErrorMessage && errorEl) errorEl.textContent = 'Ошибка поиска пользователей.';
return;
}
if (searchMetaEl) searchMetaEl.textContent = 'Пользователи с таким префиксом не найдены.';
};
const close = (payload = null) => {
closed = true;
clearSearchTimer();
root.innerHTML = '';
resolve(payload);
};
const submit = () => {
const relationType = String(kindEl.value || '').trim();
const toLogin = String(loginEl.value || '').trim();
if (!relationType) {
if (errorEl) errorEl.textContent = 'Выберите тип связи.';
return;
}
if (!toLogin) {
if (errorEl) errorEl.textContent = 'Введите логин пользователя.';
return;
}
close({ relationType, toLogin });
};
modal.addEventListener('click', (event) => {
if (event.target === modal) close(null);
});
cancelEl?.addEventListener('click', () => close(null));
submitEl?.addEventListener('click', submit);
suggestEl?.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest('[data-login]');
if (!(button instanceof HTMLElement)) return;
const pickedLogin = String(button.dataset.login || '').trim();
if (!pickedLogin) return;
loginEl.value = pickedLogin;
if (errorEl) errorEl.textContent = '';
if (searchMetaEl) searchMetaEl.textContent = `Выбран пользователь «${pickedLogin}». Нажмите «Продолжить».`;
renderSuggestions([]);
});
loginEl.addEventListener('input', () => {
if (errorEl) errorEl.textContent = '';
clearSearchTimer();
const value = String(loginEl.value || '').trim();
if (!value) {
renderSuggestions([]);
if (searchMetaEl) {
searchMetaEl.textContent = 'Начните вводить логин. Поиск запустится через 2 секунды паузы или по Enter.';
}
return;
}
if (searchMetaEl) searchMetaEl.textContent = 'Ожидание паузы 2 секунды для поиска...';
searchTimerId = window.setTimeout(() => {
runSearch({ withErrorMessage: false });
}, 2000);
});
loginEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
clearSearchTimer();
runSearch({ withErrorMessage: true });
}
});
window.setTimeout(() => loginEl.focus(), 0);
});
}
function renderFields(fields) {
listWrap.innerHTML = '';
fields.forEach((field) => {
const row = document.createElement('button');
row.type = 'button';
row.className = 'nav-row profile-param-item';
row.dataset.editField = field.key;
const value = String(field.value || '').trim();
row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
listWrap.append(row);
if (field.key === 'last_name') {
const genderRow = document.createElement('button');
genderRow.type = 'button';
genderRow.className = 'nav-row profile-param-item';
genderRow.dataset.editGender = 'true';
const genderText = genderLabel(currentGender);
const genderEmpty = genderText === 'Не указан';
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
listWrap.append(genderRow);
}
});
}
async function refreshProfileSnapshot() {
status.className = 'status-line';
status.textContent = 'Загрузка параметров...';
reloadBtn.disabled = true;
accountRoleBtn.disabled = true;
shineBtn.disabled = true;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
try {
const snapshot = await loadProfileSnapshot(login);
currentFields = snapshot.fields;
currentAccountRole = snapshot.accountRole || '';
currentShineStatus = snapshot.shineStatus || '';
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', sha256Hex: '', timeMs: 0 };
syncIdentity();
renderFields(currentFields);
updateStatusesUi();
updateGenderUi();
updateAvatarUi();
status.className = 'status-line';
status.textContent = '';
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось загрузить параметры: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert('Ошибка загрузки параметров профиля', error);
} finally {
reloadBtn.disabled = false;
accountRoleBtn.disabled = false;
shineBtn.disabled = false;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
}
}
async function onChangeAvatarClick() {
status.className = 'status-line';
status.textContent = 'Открываем мастер аватара...';
try {
await openAvatarWizard({
login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
navigate,
onStatus: (message) => {
status.className = 'status-line';
status.textContent = String(message || '');
},
onAvatarSaved: async () => {
status.className = 'status-line';
status.textContent = 'Сохраняем аватар в профиль...';
await refreshProfileSnapshot();
status.className = 'status-line is-available';
status.textContent = 'Аватар обновлён.';
},
});
if (!status.textContent) {
status.className = 'status-line';
}
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = error?.message || 'Не удалось открыть мастер аватара.';
}
}
async function onStatusClick(statusKey) {
const isAccountRole = statusKey === 'account_role';
const picked = await openStatusPickerModal({
title: isAccountRole ? 'Роль аккаунта' : 'Статус сияния',
value: isAccountRole ? currentAccountRole : currentShineStatus,
options: isAccountRole
? [
{ value: PROFILE_ACCOUNT_ROLE_PRIMARY, label: 'Основной аккаунт' },
{ value: PROFILE_ACCOUNT_ROLE_NON_VOTING, label: 'Не учитывать мой голос' },
]
: [
{ value: PROFILE_SHINE_SHINING, label: 'Сияющий' },
{ value: PROFILE_SHINE_UNKNOWN, label: 'Неизвестно' },
{ value: PROFILE_SHINE_NOT_INTERESTED, label: 'Сияние мне неинтересно' },
],
});
if (!picked) return;
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
try {
await saveProfileStatus(login, statusKey, picked);
await refreshProfileSnapshot();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось изменить ${statusKey}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${statusKey}`, error);
}
}
async function onEditFieldClick(fieldKey) {
const field = currentFields.find((item) => item.key === fieldKey);
if (!field) return;
const entered = await openFieldEditModal({
label: field.label,
value: field.value || '',
placeholder: field.placeholder || '',
maxLength: field.maxLength || 300,
multiline: Boolean(field.multiline),
});
if (entered === null) return;
if (String(entered).length > Number(field.maxLength || 300)) {
window.alert(`Максимальная длина поля «${field.label}» – ${Number(field.maxLength || 300)} символов.`);
return;
}
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
try {
await saveProfileParamBlock(login, field.key, entered);
await refreshProfileSnapshot();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось изменить ${field.key}: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert(`Ошибка изменения ${field.key}`, error);
}
}
async function onGenderClick() {
const nextGender = await openGenderPickerModal(currentGender);
if (!nextGender) return;
status.className = 'status-line';
status.textContent = 'Сохранение в блокчейн...';
try {
await saveProfileGender(login, nextGender);
await refreshProfileSnapshot();
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось изменить пол: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert('Ошибка изменения пола', error);
}
}
async function onAddRelativeClick() {
const sessionLogin = String(login || '').trim();
if (!sessionLogin) {
window.alert('Для добавления связи нужен активный логин.');
return;
}
status.className = 'status-line';
status.textContent = 'Подготовка окна добавления связи...';
let contacts = [];
try {
const payload = await authService.listContacts();
contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
} catch {
contacts = [];
}
const picked = await openAddRelativeModal({ contacts });
if (!picked) {
status.className = 'status-line is-available';
status.textContent = 'Добавление связи отменено.';
return;
}
const relationType = String(picked.relationType || '').trim().toLowerCase();
const inputLogin = String(picked.toLogin || '').trim();
if (!relationType || !inputLogin) return;
if (inputLogin.toLowerCase() === sessionLogin.toLowerCase()) {
status.className = 'status-line is-unavailable';
status.textContent = 'Нельзя добавить связь с самим собой.';
return;
}
status.className = 'status-line';
status.textContent = 'Проверка пользователя...';
let targetCard;
try {
targetCard = await loadUserProfileCard(inputLogin);
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Пользователь не найден: ${error.message || 'unknown'}`;
showLocalErrorAlert('Ошибка выбора пользователя', error);
return;
}
const targetLogin = String(targetCard?.login || inputLogin).trim();
const targetGender = normalizeGender(targetCard?.gender || PROFILE_GENDER_UNKNOWN);
const relationLabel = relationAccusativeLabel(relationType, targetGender);
const confirmed = window.confirm(
`Добавить пользователя ${targetLogin} как ${relationLabel}?`,
);
if (!confirmed) return;
status.className = 'status-line';
status.textContent = 'Сохранение связи...';
try {
if (!state.session.storagePwdInMemory) {
throw new Error('Нет storagePwd в памяти сессии. Выполните вход заново.');
}
await authService.setUserRelation({
login: sessionLogin,
toLogin: targetLogin,
kind: relationType,
enabled: true,
storagePwd: state.session.storagePwdInMemory,
});
status.className = 'status-line is-available';
status.textContent = `Связь добавлена: ${targetLogin} как ${relationLabel}.`;
} catch (error) {
status.className = 'status-line is-unavailable';
status.textContent = `Не удалось добавить связь: ${error.message || 'ошибка сети'}`;
showLocalErrorAlert('Ошибка добавления связи', error);
}
}
listWrap.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const genderRow = target.closest('[data-edit-gender="true"]');
if (genderRow) {
onGenderClick();
return;
}
const fieldRow = target.closest('[data-edit-field]');
const fieldKey = String(fieldRow?.getAttribute('data-edit-field') || '');
if (!fieldKey) return;
onEditFieldClick(fieldKey);
});
reloadBtn.addEventListener('click', refreshProfileSnapshot);
accountRoleBtn.addEventListener('click', () => onStatusClick('account_role'));
shineBtn.addEventListener('click', () => onStatusClick('shine'));
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
avatarActionEl?.addEventListener('click', () => { void onChangeAvatarClick(); });
card.append(topRow, badgesRow, status, listWrap, relativesCard);
screen.append(card);
updateAvatarUi();
refreshProfileSnapshot();
return screen;
}