Добавить аватар профиля через Arweave и мастер загрузки

This commit is contained in:
AidarKC
2026-04-26 01:41:09 +03:00
parent 126cf2f5c3
commit 667c5310bf
10 changed files with 1002 additions and 10 deletions
+102 -1
View File
@@ -11,6 +11,8 @@ import {
saveProfileToggle,
} from '../services/user-profile-params.js';
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.js';
import { buildArweaveDataUrl } from '../services/arweave-file-service.js';
import { openAvatarWizard } from '../components/avatar-wizard.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -102,7 +104,13 @@ export function render({ navigate }) {
topRow.className = 'row';
topRow.innerHTML = `
<div class="row" style="gap:12px; align-items:center;">
<div class="avatar large">${profile.avatarInitials}</div>
<div class="profile-avatar-block">
<div class="avatar large profile-avatar" data-profile-avatar="true">
<span data-avatar-fallback="true">${profile.avatarInitials}</span>
<img src="" alt="Аватар" data-avatar-image="true" hidden />
</div>
<button class="ghost-btn profile-avatar-change-btn" type="button" data-change-avatar="true">Сменить аватар</button>
</div>
<div class="profile-identity-lines" data-profile-identity="true">
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
</div>
@@ -139,11 +147,15 @@ export function render({ navigate }) {
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
const changeAvatarBtn = topRow.querySelector('[data-change-avatar="true"]');
let currentFields = [];
let currentToggles = [];
let currentGender = PROFILE_GENDER_UNKNOWN;
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
const avatarImageEl = topRow.querySelector('[data-avatar-image="true"]');
const avatarFallbackEl = topRow.querySelector('[data-avatar-fallback="true"]');
function openGenderPickerModal(initialGender) {
const root = document.getElementById('modal-root');
@@ -207,6 +219,58 @@ export function render({ navigate }) {
)).join('');
}
function buildAvatarFallbackText() {
const firstName = String(currentFields.find((field) => field.key === 'first_name')?.value || '').trim();
const lastName = String(currentFields.find((field) => field.key === 'last_name')?.value || '').trim();
const fromNames = `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
if (fromNames.trim()) return fromNames;
const loginFirst = String(login || '').trim().charAt(0).toUpperCase();
if (loginFirst) return loginFirst;
return String(profile.avatarInitials || '??').trim().slice(0, 2).toUpperCase();
}
function showAvatarFallback() {
if (avatarImageEl instanceof HTMLImageElement) {
avatarImageEl.hidden = true;
avatarImageEl.removeAttribute('src');
}
if (avatarFallbackEl instanceof HTMLElement) {
avatarFallbackEl.hidden = false;
avatarFallbackEl.textContent = buildAvatarFallbackText();
}
}
function updateAvatarUi() {
if (!(avatarImageEl instanceof HTMLImageElement)) return;
const txId = String(currentAvatar?.txId || '').trim();
if (!txId) {
showAvatarFallback();
return;
}
let avatarUrl = '';
try {
avatarUrl = buildArweaveDataUrl({
gateway: state.entrySettings.arweaveServer,
txId,
});
} catch {
showAvatarFallback();
return;
}
avatarImageEl.onload = () => {
if (avatarFallbackEl instanceof HTMLElement) avatarFallbackEl.hidden = true;
avatarImageEl.hidden = false;
};
avatarImageEl.onerror = () => {
showAvatarFallback();
};
avatarImageEl.src = avatarUrl;
}
function updateToggleButton(button, prefix, enabled) {
button.textContent = `${prefix}: ${toggleText(enabled)}`;
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
@@ -545,6 +609,7 @@ export function render({ navigate }) {
officialBtn.disabled = true;
shineBtn.disabled = true;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = true;
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
if (genderActionBtn instanceof HTMLButtonElement) {
genderActionBtn.disabled = true;
@@ -555,11 +620,13 @@ export function render({ navigate }) {
currentFields = snapshot.fields;
currentToggles = snapshot.toggles;
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
syncIdentity();
renderFields(currentFields);
updateTogglesUi();
updateGenderUi();
updateAvatarUi();
status.className = 'status-line';
status.textContent = '';
@@ -572,6 +639,7 @@ export function render({ navigate }) {
officialBtn.disabled = false;
shineBtn.disabled = false;
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = false;
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
if (genderActionBtnAfter instanceof HTMLButtonElement) {
genderActionBtnAfter.disabled = false;
@@ -579,6 +647,37 @@ export function render({ navigate }) {
}
}
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 onToggleClick(toggleKey) {
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
const nextEnabled = !toggle.enabled;
@@ -742,10 +841,12 @@ export function render({ navigate }) {
officialBtn.addEventListener('click', () => onToggleClick('official'));
shineBtn.addEventListener('click', () => onToggleClick('shine'));
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
changeAvatarBtn?.addEventListener('click', () => { void onChangeAvatarClick(); });
card.append(topRow, badgesRow, status, listWrap, relativesCard);
screen.append(card);
showAvatarFallback();
refreshProfileSnapshot();
return screen;
+24 -2
View File
@@ -316,7 +316,18 @@ export function render({ navigate }) {
`;
const addressEl = addressCard.querySelector('#wallet-address-value');
card.append(balanceWrap, addressCard);
const helpCard = document.createElement('details');
helpCard.className = 'card';
helpCard.style.padding = '10px';
helpCard.innerHTML = `
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
<p class="meta-muted" style="margin-top:8px;">
SHiNE берёт ваш локальный device.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
</p>
`;
card.append(balanceWrap, addressCard, helpCard);
const actions = document.createElement('div');
actions.className = 'stack';
@@ -412,7 +423,18 @@ export function render({ navigate }) {
setStatus('Генерация Arweave-кошелька...');
try {
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey(sessionArgsOrThrow());
arweaveWalletCtx = await getArweaveWalletFromStoredDeviceKey({
...sessionArgsOrThrow(),
onStatus: (message) => {
const text = String(message || '').trim();
if (!text) return;
if (text.includes('впервые получаем Arweave-кошелёк')) {
setStatus('Сейчас мы впервые получаем Arweave-кошелёк из вашего приватного device key. Это может занять немного времени. После этого кошелёк будет храниться только в зашифрованном контейнере этого устройства.');
return;
}
setStatus(text);
},
});
if (modeToken !== activeModeToken) return;
walletAddress = arweaveWalletCtx.address;
addressEl.textContent = walletAddress;