SHiNE-server/shine-UI/js/components/avatar-wizard.js

453 lines
20 KiB
JavaScript
Raw Permalink 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 { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
prepareAvatarImageFile,
sha256HexFromArrayBuffer,
uploadArweaveFile,
validateArweaveTxId,
validateSha256Hex,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
function escapeHtml(text) {
return String(text || '')
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function formatAr(ar) {
const n = Number(ar);
if (!Number.isFinite(n)) return '0';
return n.toLocaleString('ru-RU', { maximumFractionDigits: 6 });
}
function clearJwk(ctx) {
if (!ctx?.jwk || typeof ctx.jwk !== 'object') return;
Object.keys(ctx.jwk).forEach((key) => {
ctx.jwk[key] = '';
});
ctx.jwk = null;
}
function setNodeText(node, text) {
if (node) node.textContent = String(text || '');
}
export function openAvatarWizard({
login,
storagePwd,
gateway,
navigate,
onAvatarSaved,
onStatus,
} = {}) {
const root = document.getElementById('modal-root');
if (!root) return Promise.resolve(false);
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
const cleanGateway = String(gateway || '').trim();
if (!cleanLogin || !cleanStoragePwd) {
return Promise.reject(new Error('Нет активной сессии.'));
}
let closed = false;
let lastPreviewUrl = '';
let walletCtx = null;
let balanceInfo = null;
let optimized = null;
let priceInfo = null;
let uploadedTxId = '';
let uploadedSha256Hex = '';
function revokePreviewUrl() {
if (!lastPreviewUrl) return;
URL.revokeObjectURL(lastPreviewUrl);
lastPreviewUrl = '';
}
function close(result = false, resolve) {
if (closed) return;
closed = true;
revokePreviewUrl();
clearJwk(walletCtx);
walletCtx = null;
root.innerHTML = '';
resolve(result);
}
async function ensurePreviewImage(url, imageEl) {
return new Promise((resolve, reject) => {
const img = imageEl;
img.onload = () => resolve(true);
img.onerror = () => reject(new Error('Не удалось загрузить изображение по этому Transaction ID'));
img.src = url;
});
}
return new Promise((resolve) => {
const showStepChoice = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Сменить аватар</h3>
<p class="meta-muted">Вы можете использовать уже загруженный файл Arweave или загрузить новый файл.</p>
<div class="avatar-wizard-choice-grid">
<button class="primary-btn" type="button" data-action="use-existing">Использовать существующий файл в Arweave</button>
<button class="primary-btn" type="button" data-action="upload-new">Загрузить новый файл в Arweave</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
root.querySelector('[data-action="use-existing"]')?.addEventListener('click', showStepExistingInput);
root.querySelector('[data-action="upload-new"]')?.addEventListener('click', () => { void showStepUpload(); });
};
const showStepExistingInput = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Использовать существующий файл в Arweave</h3>
<label class="meta-muted" for="avatar-existing-txid">Transaction ID Arweave</label>
<input class="input" id="avatar-existing-txid" type="text" maxlength="64" placeholder="Введите Transaction ID" />
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="next">Далее</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const inputEl = root.querySelector('#avatar-existing-txid');
const errorEl = root.querySelector('[data-error="true"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
root.querySelector('[data-action="next"]')?.addEventListener('click', () => {
const txId = String(inputEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setNodeText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
setNodeText(errorEl, '');
void showStepExistingPreview(txId);
});
window.setTimeout(() => inputEl?.focus(), 0);
};
const showStepExistingPreview = async (txId) => {
if (closed) return;
const previewUrl = buildArweaveDataUrl({ gateway: cleanGateway, txId });
let existingSha256Hex = '';
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Предпросмотр аватара</h3>
<div class="avatar-preview-circle avatar-wizard-preview">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="save">Сохранить аватар</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const imageEl = root.querySelector('[data-preview-image="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const saveBtn = root.querySelector('[data-action="save"]');
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepExistingInput);
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
try {
await ensurePreviewImage(previewUrl, imageEl);
const response = await fetch(previewUrl, { method: 'GET', cache: 'no-store' });
if (!response.ok) throw new Error('BAD_AVATAR_FETCH');
existingSha256Hex = await sha256HexFromArrayBuffer(await response.arrayBuffer());
if (!validateSha256Hex(existingSha256Hex)) throw new Error('BAD_AVATAR_HASH');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = false;
} catch (error) {
setNodeText(errorEl, 'Не удалось проверить файл по этому Transaction ID.');
if (saveBtn instanceof HTMLButtonElement) saveBtn.disabled = true;
}
saveBtn?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, txId, existingSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
};
const showStepZeroBalance = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Недостаточно средств</h3>
<p class="meta-muted">У вас нулевой баланс Arweave. Для загрузки аватара нужен AR.</p>
<div class="avatar-wizard-actions">
<button class="primary-btn" type="button" data-action="to-wallet">Перейти в кошелёк</button>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
</div>
</div>
</div>
`;
root.querySelector('[data-action="to-wallet"]')?.addEventListener('click', () => {
close(false, resolve);
if (typeof navigate === 'function') navigate('wallet-view');
});
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUploadForm = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Загрузить новый файл в Arweave</h3>
<p class="meta-muted">Баланс: ${formatAr(balanceInfo?.ar)} AR</p>
<label class="meta-muted" for="avatar-file-input">Выберите изображение</label>
<input class="input" id="avatar-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Максимальный исходный файл — 10 MB. Перед загрузкой изображение будет уменьшено для аватарки.</p>
<div class="avatar-preview-circle avatar-wizard-preview" hidden data-preview-wrap="true">
<img alt="Предпросмотр аватара" data-preview-image="true" />
</div>
<div class="avatar-wizard-meta" data-meta="true"></div>
<p class="meta-muted">После сохранения в профиль будут записаны SHA-256 и Transaction ID. Сам файл хранится в Arweave.</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить в Arweave</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
const fileInput = root.querySelector('#avatar-file-input');
const errorEl = root.querySelector('[data-error="true"]');
const metaEl = root.querySelector('[data-meta="true"]');
const previewWrap = root.querySelector('[data-preview-wrap="true"]');
const previewImage = root.querySelector('[data-preview-image="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
let selectedFile = null;
optimized = null;
priceInfo = null;
uploadedSha256Hex = '';
modal?.addEventListener('click', (event) => {
if (event.target === modal) close(false, resolve);
});
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
fileInput?.addEventListener('change', async () => {
setNodeText(errorEl, '');
setNodeText(metaEl, '');
uploadBtn.disabled = true;
revokePreviewUrl();
selectedFile = fileInput.files?.[0] || null;
if (!selectedFile) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
try {
validateAvatarSourceFile(selectedFile);
optimized = await prepareAvatarImageFile(selectedFile);
priceInfo = await getArweaveUploadPrice({
gateway: cleanGateway,
byteLength: optimized.file.size,
});
lastPreviewUrl = URL.createObjectURL(optimized.file);
previewImage.src = lastPreviewUrl;
previewWrap.hidden = false;
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Исходный размер: ${escapeHtml(formatBytes(optimized.originalSizeBytes))}</div>
<div>Итоговый размер: ${escapeHtml(formatBytes(optimized.optimizedSizeBytes))}</div>
<div>Итоговое разрешение: ${escapeHtml(`${optimized.width} × ${optimized.height}`)}</div>
<div>Тип файла: ${escapeHtml(optimized.contentType)}</div>
<div>SHA-256: ${escapeHtml(String(optimized.sha256Hex || '').toLowerCase())}</div>
<div>Примерная цена загрузки: ${escapeHtml(formatAr(priceInfo.ar))} AR</div>
`;
if (!hasFunds) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
uploadBtn.disabled = true;
return;
}
uploadBtn.disabled = false;
} catch (error) {
const message = String(error?.message || '');
if (message === 'Выберите файл изображения.' || message === 'Поддерживаются только JPEG, PNG или WebP.' || message === 'Файл слишком большой. Максимум 10 MB.') {
setNodeText(errorEl, message);
} else if (message.includes('цену загрузки')) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
} else {
setNodeText(errorEl, 'Не удалось подготовить изображение.');
}
}
});
uploadBtn?.addEventListener('click', async () => {
setNodeText(errorEl, '');
if (!optimized?.file) {
setNodeText(errorEl, 'Выберите файл изображения.');
return;
}
if (!priceInfo || BigInt(balanceInfo.winston) < BigInt(priceInfo.winston)) {
setNodeText(errorEl, 'Недостаточно средств для загрузки.');
return;
}
uploadBtn.disabled = true;
try {
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: cleanLogin },
],
});
uploadedTxId = String(uploaded.id || '').trim();
uploadedSha256Hex = String(optimized?.sha256Hex || '').trim().toLowerCase();
if (!uploadedTxId) {
throw new Error('Пустой Transaction ID');
}
if (!validateSha256Hex(uploadedSha256Hex)) {
throw new Error('Некорректный SHA256');
}
showStepUploaded();
} catch {
setNodeText(errorEl, 'Не удалось загрузить файл в Arweave.');
uploadBtn.disabled = false;
}
});
};
const showStepUploaded = () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Файл загружен в Arweave</h3>
<p class="meta-muted">Transaction ID:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
<p class="meta-muted">SHA-256:</p>
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="ghost-btn" type="button" data-action="copy-id">Скопировать ID</button>
<button class="primary-btn" type="button" data-action="set-avatar">Сделать аватаром</button>
</div>
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
</div>
</div>
`;
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="copy-id"]')?.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(uploadedTxId);
setNodeText(errorEl, '');
} catch {
setNodeText(errorEl, 'Не удалось скопировать Transaction ID.');
}
});
root.querySelector('[data-action="set-avatar"]')?.addEventListener('click', async () => {
try {
await saveProfileAvatarArweave(cleanLogin, uploadedTxId, uploadedSha256Hex);
if (typeof onAvatarSaved === 'function') await onAvatarSaved();
close(true, resolve);
} catch {
setNodeText(errorEl, 'Не удалось сохранить аватар в профиль.');
}
});
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
};
const showStepUpload = async () => {
if (closed) return;
root.innerHTML = `
<div class="modal" data-avatar-wizard-modal="true">
<div class="modal-card stack avatar-wizard-card">
<h3 class="modal-title">Подготовка загрузки</h3>
<p class="meta-muted" data-loading="true">Загружаем Arweave-кошелёк...</p>
<p class="avatar-wizard-error" data-error="true"></p>
<div class="avatar-wizard-actions">
<button class="secondary-btn" type="button" data-action="back">Назад</button>
</div>
</div>
</div>
`;
const loadingEl = root.querySelector('[data-loading="true"]');
const errorEl = root.querySelector('[data-error="true"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
try {
walletCtx = await getArweaveWalletFromStoredDeviceKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
onStatus: (message) => {
setNodeText(loadingEl, message);
if (typeof onStatus === 'function') onStatus(message);
},
});
balanceInfo = await getArweaveBalance({
gateway: cleanGateway,
address: walletCtx.address,
});
} catch (error) {
setNodeText(errorEl, 'Не удалось получить баланс Arweave.');
return;
}
if (BigInt(balanceInfo.winston) <= 0n) {
showStepZeroBalance();
return;
}
showStepUploadForm();
};
showStepChoice();
});
}