437 lines
19 KiB
JavaScript
437 lines
19 KiB
JavaScript
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
|
||
import {
|
||
buildArweaveDataUrl,
|
||
getArweaveUploadPrice,
|
||
prepareAvatarImageFile,
|
||
uploadArweaveFile,
|
||
validateArweaveTxId,
|
||
validateAvatarSourceFile,
|
||
} from '../services/arweave-file-service.js';
|
||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||
|
||
function escapeHtml(text) {
|
||
return String(text || '')
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
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 = '';
|
||
|
||
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 });
|
||
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">После сохранения в профиль будет записан только 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);
|
||
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);
|
||
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">После сохранения в профиль будет записан только 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;
|
||
|
||
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>Примерная цена загрузки: ${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-Login', value: cleanLogin },
|
||
],
|
||
});
|
||
uploadedTxId = String(uploaded.id || '').trim();
|
||
if (!uploadedTxId) {
|
||
throw new Error('Пустой Transaction ID');
|
||
}
|
||
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="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);
|
||
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();
|
||
});
|
||
}
|