Добавить аватар профиля через Arweave и мастер загрузки
This commit is contained in:
parent
126cf2f5c3
commit
667c5310bf
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.4
|
client.version=1.2.5
|
||||||
server.version=1.2.4
|
server.version=1.2.5
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
<link rel="manifest" href="./manifest.webmanifest" />
|
<link rel="manifest" href="./manifest.webmanifest" />
|
||||||
<title>Shine UI Demo</title>
|
<title>Shine UI Demo</title>
|
||||||
<script>
|
<script>
|
||||||
window.__SHINE_BUILD_HASH__ = '20260426011500';
|
window.__SHINE_BUILD_HASH__ = '20260426025000';
|
||||||
window.__SHINE_CLIENT_VERSION__ = '1.2.4';
|
window.__SHINE_CLIENT_VERSION__ = '1.2.5';
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function attachStylesWithBuildHash() {
|
(function attachStylesWithBuildHash() {
|
||||||
|
|||||||
436
shine-UI/js/components/avatar-wizard.js
Normal file
436
shine-UI/js/components/avatar-wizard.js
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@ import {
|
|||||||
saveProfileToggle,
|
saveProfileToggle,
|
||||||
} from '../services/user-profile-params.js';
|
} from '../services/user-profile-params.js';
|
||||||
import { buildIdentityLines, loadUserProfileCard } from '../services/user-connections.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: 'Профиль' };
|
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||||
|
|
||||||
@ -102,7 +104,13 @@ export function render({ navigate }) {
|
|||||||
topRow.className = 'row';
|
topRow.className = 'row';
|
||||||
topRow.innerHTML = `
|
topRow.innerHTML = `
|
||||||
<div class="row" style="gap:12px; align-items:center;">
|
<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-lines" data-profile-identity="true">
|
||||||
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
|
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -139,11 +147,15 @@ export function render({ navigate }) {
|
|||||||
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
const officialBtn = badgesRow.querySelector('[data-toggle="official"]');
|
||||||
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
const shineBtn = badgesRow.querySelector('[data-toggle="shine"]');
|
||||||
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
|
const addRelativeBtn = relativesCard.querySelector('[data-add-relative="true"]');
|
||||||
|
const changeAvatarBtn = topRow.querySelector('[data-change-avatar="true"]');
|
||||||
|
|
||||||
let currentFields = [];
|
let currentFields = [];
|
||||||
let currentToggles = [];
|
let currentToggles = [];
|
||||||
let currentGender = PROFILE_GENDER_UNKNOWN;
|
let currentGender = PROFILE_GENDER_UNKNOWN;
|
||||||
|
let currentAvatar = { value: '', source: '', txId: '', timeMs: 0 };
|
||||||
const identityEl = topRow.querySelector('[data-profile-identity="true"]');
|
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) {
|
function openGenderPickerModal(initialGender) {
|
||||||
const root = document.getElementById('modal-root');
|
const root = document.getElementById('modal-root');
|
||||||
@ -207,6 +219,58 @@ export function render({ navigate }) {
|
|||||||
)).join('');
|
)).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) {
|
function updateToggleButton(button, prefix, enabled) {
|
||||||
button.textContent = `${prefix}: ${toggleText(enabled)}`;
|
button.textContent = `${prefix}: ${toggleText(enabled)}`;
|
||||||
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
|
button.classList.remove('is-no', 'is-yes-official', 'is-yes-shine');
|
||||||
@ -545,6 +609,7 @@ export function render({ navigate }) {
|
|||||||
officialBtn.disabled = true;
|
officialBtn.disabled = true;
|
||||||
shineBtn.disabled = true;
|
shineBtn.disabled = true;
|
||||||
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = true;
|
||||||
|
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = true;
|
||||||
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
|
const genderActionBtn = listWrap.querySelector('[data-edit-gender="true"]');
|
||||||
if (genderActionBtn instanceof HTMLButtonElement) {
|
if (genderActionBtn instanceof HTMLButtonElement) {
|
||||||
genderActionBtn.disabled = true;
|
genderActionBtn.disabled = true;
|
||||||
@ -555,11 +620,13 @@ export function render({ navigate }) {
|
|||||||
currentFields = snapshot.fields;
|
currentFields = snapshot.fields;
|
||||||
currentToggles = snapshot.toggles;
|
currentToggles = snapshot.toggles;
|
||||||
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
currentGender = snapshot.gender || PROFILE_GENDER_UNKNOWN;
|
||||||
|
currentAvatar = snapshot.avatar || { value: '', source: '', txId: '', timeMs: 0 };
|
||||||
|
|
||||||
syncIdentity();
|
syncIdentity();
|
||||||
renderFields(currentFields);
|
renderFields(currentFields);
|
||||||
updateTogglesUi();
|
updateTogglesUi();
|
||||||
updateGenderUi();
|
updateGenderUi();
|
||||||
|
updateAvatarUi();
|
||||||
|
|
||||||
status.className = 'status-line';
|
status.className = 'status-line';
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
@ -572,6 +639,7 @@ export function render({ navigate }) {
|
|||||||
officialBtn.disabled = false;
|
officialBtn.disabled = false;
|
||||||
shineBtn.disabled = false;
|
shineBtn.disabled = false;
|
||||||
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
|
if (addRelativeBtn instanceof HTMLButtonElement) addRelativeBtn.disabled = false;
|
||||||
|
if (changeAvatarBtn instanceof HTMLButtonElement) changeAvatarBtn.disabled = false;
|
||||||
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
|
const genderActionBtnAfter = listWrap.querySelector('[data-edit-gender="true"]');
|
||||||
if (genderActionBtnAfter instanceof HTMLButtonElement) {
|
if (genderActionBtnAfter instanceof HTMLButtonElement) {
|
||||||
genderActionBtnAfter.disabled = false;
|
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) {
|
async function onToggleClick(toggleKey) {
|
||||||
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
|
const toggle = currentToggles.find((item) => item.key === toggleKey) || { enabled: false };
|
||||||
const nextEnabled = !toggle.enabled;
|
const nextEnabled = !toggle.enabled;
|
||||||
@ -742,10 +841,12 @@ export function render({ navigate }) {
|
|||||||
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
officialBtn.addEventListener('click', () => onToggleClick('official'));
|
||||||
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
shineBtn.addEventListener('click', () => onToggleClick('shine'));
|
||||||
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
|
addRelativeBtn?.addEventListener('click', onAddRelativeClick);
|
||||||
|
changeAvatarBtn?.addEventListener('click', () => { void onChangeAvatarClick(); });
|
||||||
|
|
||||||
card.append(topRow, badgesRow, status, listWrap, relativesCard);
|
card.append(topRow, badgesRow, status, listWrap, relativesCard);
|
||||||
screen.append(card);
|
screen.append(card);
|
||||||
|
|
||||||
|
showAvatarFallback();
|
||||||
refreshProfileSnapshot();
|
refreshProfileSnapshot();
|
||||||
|
|
||||||
return screen;
|
return screen;
|
||||||
|
|||||||
@ -316,7 +316,18 @@ export function render({ navigate }) {
|
|||||||
`;
|
`;
|
||||||
const addressEl = addressCard.querySelector('#wallet-address-value');
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'stack';
|
actions.className = 'stack';
|
||||||
@ -412,7 +423,18 @@ export function render({ navigate }) {
|
|||||||
setStatus('Генерация Arweave-кошелька...');
|
setStatus('Генерация Arweave-кошелька...');
|
||||||
|
|
||||||
try {
|
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;
|
if (modeToken !== activeModeToken) return;
|
||||||
walletAddress = arweaveWalletCtx.address;
|
walletAddress = arweaveWalletCtx.address;
|
||||||
addressEl.textContent = walletAddress;
|
addressEl.textContent = walletAddress;
|
||||||
|
|||||||
271
shine-UI/js/services/arweave-file-service.js
Normal file
271
shine-UI/js/services/arweave-file-service.js
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
const DEFAULT_ARWEAVE_GATEWAY = 'https://arweave.net';
|
||||||
|
const WINSTON_PER_AR = 1_000_000_000_000n;
|
||||||
|
const MAX_AVATAR_SOURCE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const MAX_AVATAR_SIDE_PX = 768;
|
||||||
|
const AVATAR_QUALITY = 0.86;
|
||||||
|
const TX_ID_RE = /^[A-Za-z0-9_-]{43}$/;
|
||||||
|
|
||||||
|
let arweaveLibPromise = null;
|
||||||
|
|
||||||
|
function normalizeGateway(rawGateway) {
|
||||||
|
const raw = String(rawGateway || '').trim();
|
||||||
|
if (!raw) return DEFAULT_ARWEAVE_GATEWAY;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Некорректный Arweave gateway URL');
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error('Arweave gateway должен использовать http или https');
|
||||||
|
}
|
||||||
|
const normalized = `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/+$/g, '');
|
||||||
|
return normalized || DEFAULT_ARWEAVE_GATEWAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGatewayForArweaveInit(gateway) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(gateway);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Некорректный Arweave gateway URL');
|
||||||
|
}
|
||||||
|
const protocol = parsed.protocol.replace(':', '');
|
||||||
|
if (protocol !== 'http' && protocol !== 'https') {
|
||||||
|
throw new Error('Arweave gateway должен использовать http или https');
|
||||||
|
}
|
||||||
|
const port = parsed.port
|
||||||
|
? Number(parsed.port)
|
||||||
|
: (protocol === 'https' ? 443 : 80);
|
||||||
|
return {
|
||||||
|
protocol,
|
||||||
|
host: parsed.hostname,
|
||||||
|
port,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArweaveLib() {
|
||||||
|
if (!arweaveLibPromise) {
|
||||||
|
arweaveLibPromise = import('https://esm.sh/arweave@1.15.7?bundle');
|
||||||
|
}
|
||||||
|
return arweaveLibPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArFromWinston(winston) {
|
||||||
|
return Number(BigInt(winston)) / Number(WINSTON_PER_AR);
|
||||||
|
}
|
||||||
|
|
||||||
|
function blobToFile(blob, name, type) {
|
||||||
|
const effectiveType = String(type || blob.type || 'application/octet-stream');
|
||||||
|
if (typeof File === 'function') {
|
||||||
|
return new File([blob], name, { type: effectiveType });
|
||||||
|
}
|
||||||
|
return Object.assign(blob, { name, lastModified: Date.now(), type: effectiveType });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBitmapWithFallback(file) {
|
||||||
|
if (typeof createImageBitmap === 'function') {
|
||||||
|
try {
|
||||||
|
return await createImageBitmap(file);
|
||||||
|
} catch {
|
||||||
|
// continue with <img> fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
try {
|
||||||
|
const image = await new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('Не удалось прочитать изображение'));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
return image;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToBlob(canvas, type, quality) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob((blob) => resolve(blob), type, quality);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArweaveTxId(txId) {
|
||||||
|
const value = String(txId || '').trim();
|
||||||
|
return TX_ID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArweaveAvatarValue(txId) {
|
||||||
|
const cleanTxId = String(txId || '').trim();
|
||||||
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
|
throw new Error('Некорректный Transaction ID Arweave');
|
||||||
|
}
|
||||||
|
return `AR:${cleanTxId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArweaveAvatarValue(value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw.startsWith('AR:')) {
|
||||||
|
return { ok: false, network: '', txId: '' };
|
||||||
|
}
|
||||||
|
const txId = raw.slice(3).trim();
|
||||||
|
if (!validateArweaveTxId(txId)) {
|
||||||
|
return { ok: false, network: '', txId: '' };
|
||||||
|
}
|
||||||
|
return { ok: true, network: 'AR', txId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildArweaveDataUrl({ gateway, txId }) {
|
||||||
|
const normalizedGateway = normalizeGateway(gateway);
|
||||||
|
const cleanTxId = String(txId || '').trim();
|
||||||
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
|
throw new Error('Некорректный Transaction ID Arweave');
|
||||||
|
}
|
||||||
|
return `${normalizedGateway}/${cleanTxId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArweaveUploadPrice({ gateway, byteLength }) {
|
||||||
|
const normalizedGateway = normalizeGateway(gateway);
|
||||||
|
const bytes = Number(byteLength);
|
||||||
|
if (!Number.isInteger(bytes) || bytes <= 0) {
|
||||||
|
throw new Error('Некорректный размер файла');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${normalizedGateway}/price/${bytes}`, { method: 'GET' });
|
||||||
|
const raw = String(await response.text()).trim();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Не удалось получить цену загрузки (${response.status} ${response.statusText})`);
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(raw)) {
|
||||||
|
throw new Error('Arweave gateway вернул некорректную цену');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateway: normalizedGateway,
|
||||||
|
winston: raw,
|
||||||
|
ar: toArFromWinston(raw),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSupportedAvatarImageType(type) {
|
||||||
|
const clean = String(type || '').trim().toLowerCase();
|
||||||
|
return clean === 'image/jpeg' || clean === 'image/png' || clean === 'image/webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAvatarSourceFile(file) {
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new Error('Выберите файл изображения.');
|
||||||
|
}
|
||||||
|
if (!isSupportedAvatarImageType(file.type)) {
|
||||||
|
throw new Error('Поддерживаются только JPEG, PNG или WebP.');
|
||||||
|
}
|
||||||
|
if (Number(file.size || 0) > MAX_AVATAR_SOURCE_BYTES) {
|
||||||
|
throw new Error('Файл слишком большой. Максимум 10 MB.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareAvatarImageFile(file) {
|
||||||
|
validateAvatarSourceFile(file);
|
||||||
|
|
||||||
|
let bitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createBitmapWithFallback(file);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Не удалось подготовить изображение.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const originalWidth = Number(bitmap.width || 0);
|
||||||
|
const originalHeight = Number(bitmap.height || 0);
|
||||||
|
if (!originalWidth || !originalHeight) {
|
||||||
|
throw new Error('Не удалось подготовить изображение.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const scale = Math.min(1, MAX_AVATAR_SIDE_PX / Math.max(originalWidth, originalHeight));
|
||||||
|
const width = Math.max(1, Math.round(originalWidth * scale));
|
||||||
|
const height = Math.max(1, Math.round(originalHeight * scale));
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('Не удалось подготовить изображение.');
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||||
|
|
||||||
|
let contentType = 'image/webp';
|
||||||
|
let fileName = 'avatar.webp';
|
||||||
|
let blob = await canvasToBlob(canvas, contentType, AVATAR_QUALITY);
|
||||||
|
if (!blob) {
|
||||||
|
contentType = 'image/jpeg';
|
||||||
|
fileName = 'avatar.jpg';
|
||||||
|
blob = await canvasToBlob(canvas, contentType, AVATAR_QUALITY);
|
||||||
|
}
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error('Не удалось подготовить изображение.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const optimizedFile = blobToFile(blob, fileName, contentType);
|
||||||
|
return {
|
||||||
|
file: optimizedFile,
|
||||||
|
originalSizeBytes: Number(file.size || 0),
|
||||||
|
optimizedSizeBytes: Number(optimizedFile.size || 0),
|
||||||
|
originalWidth,
|
||||||
|
originalHeight,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
contentType,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) throw error;
|
||||||
|
throw new Error('Не удалось подготовить изображение.');
|
||||||
|
} finally {
|
||||||
|
if (bitmap && typeof bitmap.close === 'function') {
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadArweaveFile({ gateway, jwk, file, tags = [] }) {
|
||||||
|
if (!jwk || typeof jwk !== 'object') {
|
||||||
|
throw new Error('Arweave-кошелёк не инициализирован.');
|
||||||
|
}
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
throw new Error('Выберите файл изображения.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedGateway = normalizeGateway(gateway);
|
||||||
|
const { host, port, protocol } = parseGatewayForArweaveInit(normalizedGateway);
|
||||||
|
const data = await file.arrayBuffer();
|
||||||
|
|
||||||
|
const moduleRef = await loadArweaveLib();
|
||||||
|
const Arweave = moduleRef?.default || moduleRef;
|
||||||
|
const arweave = Arweave.init({ host, port, protocol });
|
||||||
|
|
||||||
|
const tx = await arweave.createTransaction({ data }, jwk);
|
||||||
|
tx.addTag('Content-Type', String(file.type || 'application/octet-stream'));
|
||||||
|
tx.addTag('App-Name', 'SHiNE');
|
||||||
|
tx.addTag('SHiNE-Type', 'avatar');
|
||||||
|
|
||||||
|
const extraTags = Array.isArray(tags) ? tags : [];
|
||||||
|
extraTags.forEach((item) => {
|
||||||
|
const name = String(item?.name || '').trim();
|
||||||
|
const value = String(item?.value || '').trim();
|
||||||
|
if (!name || !value) return;
|
||||||
|
tx.addTag(name, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
await arweave.transactions.sign(tx, jwk);
|
||||||
|
const postResult = await arweave.transactions.post(tx);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: tx.id,
|
||||||
|
status: Number(postResult?.status || 0),
|
||||||
|
statusText: String(postResult?.statusText || ''),
|
||||||
|
sizeBytes: Number(file.size || 0),
|
||||||
|
contentType: String(file.type || ''),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { loadEncryptedUserSecrets } from './key-vault.js';
|
import { loadEncryptedUserSecrets, updateEncryptedUserSecrets } from './key-vault.js';
|
||||||
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
import { extractDeviceKey32FromStoredValue } from './device-key-utils.js';
|
||||||
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
|
import { deriveArweaveWalletFromDeviceKey32 } from './sawd-v1.js';
|
||||||
|
|
||||||
@ -72,7 +72,37 @@ async function loadArweaveLib() {
|
|||||||
return arweaveLibPromise;
|
return arweaveLibPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd }) {
|
function pickCachedWallet(secrets) {
|
||||||
|
const cached = secrets?.arweaveWallet;
|
||||||
|
if (!cached || typeof cached !== 'object') return null;
|
||||||
|
const derivation = String(cached.derivation || '').trim();
|
||||||
|
const address = String(cached.address || '').trim();
|
||||||
|
const owner = String(cached.owner || '').trim();
|
||||||
|
const jwk = cached.jwk;
|
||||||
|
if (derivation !== 'SAWD-v1' || !address || !owner || !jwk || typeof jwk !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!String(jwk.kty || '').trim() || !String(jwk.e || '').trim() || !String(jwk.n || '').trim() || !String(jwk.d || '').trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
derivation,
|
||||||
|
address,
|
||||||
|
owner,
|
||||||
|
jwk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeStatus(onStatus, text) {
|
||||||
|
if (typeof onStatus !== 'function') return;
|
||||||
|
try {
|
||||||
|
onStatus(String(text || ''));
|
||||||
|
} catch {
|
||||||
|
// ignore callback errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd, onStatus } = {}) {
|
||||||
const cleanLogin = String(login || '').trim();
|
const cleanLogin = String(login || '').trim();
|
||||||
const cleanPwd = String(storagePwd || '').trim();
|
const cleanPwd = String(storagePwd || '').trim();
|
||||||
if (!cleanLogin || !cleanPwd) {
|
if (!cleanLogin || !cleanPwd) {
|
||||||
@ -80,6 +110,14 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
const secrets = await loadEncryptedUserSecrets(cleanLogin, cleanPwd);
|
||||||
|
const cached = pickCachedWallet(secrets);
|
||||||
|
if (cached) {
|
||||||
|
safeStatus(onStatus, 'Arweave-кошелёк загружен.');
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
safeStatus(onStatus, 'Сейчас мы впервые получаем Arweave-кошелёк из вашего device key. Это может занять немного времени.');
|
||||||
|
|
||||||
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
|
const storedDeviceKey = String(secrets?.deviceKey || '').trim();
|
||||||
if (!storedDeviceKey) {
|
if (!storedDeviceKey) {
|
||||||
throw new Error('На устройстве не найден device.key (wallet.key)');
|
throw new Error('На устройстве не найден device.key (wallet.key)');
|
||||||
@ -93,11 +131,24 @@ export async function getArweaveWalletFromStoredDeviceKey({ login, storagePwd })
|
|||||||
deviceKey32.fill(0);
|
deviceKey32.fill(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const cachedWallet = {
|
||||||
derivation: wallet.derivation,
|
derivation: wallet.derivation,
|
||||||
address: wallet.address,
|
address: wallet.address,
|
||||||
owner: wallet.owner,
|
owner: wallet.owner,
|
||||||
jwk: wallet.jwk,
|
jwk: wallet.jwk,
|
||||||
|
createdAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateEncryptedUserSecrets(cleanLogin, cleanPwd, (nextSecrets) => ({
|
||||||
|
...nextSecrets,
|
||||||
|
arweaveWallet: cachedWallet,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
derivation: cachedWallet.derivation,
|
||||||
|
address: cachedWallet.address,
|
||||||
|
owner: cachedWallet.owner,
|
||||||
|
jwk: cachedWallet.jwk,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -65,6 +65,20 @@ export async function loadEncryptedUserSecrets(login, storagePwd) {
|
|||||||
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
|
return decryptJsonWithStoragePwd(row.encrypted, storagePwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateEncryptedUserSecrets(login, storagePwd, updater) {
|
||||||
|
if (typeof updater !== 'function') {
|
||||||
|
throw new Error('updateEncryptedUserSecrets: updater должен быть функцией');
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await loadEncryptedUserSecrets(login, storagePwd);
|
||||||
|
const next = await updater(current);
|
||||||
|
if (!next || typeof next !== 'object') {
|
||||||
|
throw new Error('updateEncryptedUserSecrets: updater должен вернуть объект secrets');
|
||||||
|
}
|
||||||
|
await saveEncryptedUserSecrets(login, storagePwd, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveSessionMaterial(login, material) {
|
export async function saveSessionMaterial(login, material) {
|
||||||
await put(STORE_SESSIONS, {
|
await put(STORE_SESSIONS, {
|
||||||
login,
|
login,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { authService, state } from '../state.js';
|
import { authService, state } from '../state.js';
|
||||||
|
import { buildArweaveAvatarValue, parseArweaveAvatarValue, validateArweaveTxId } from './arweave-file-service.js';
|
||||||
|
|
||||||
export const profileFieldDefs = [
|
export const profileFieldDefs = [
|
||||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||||
@ -118,12 +119,28 @@ export async function loadProfileSnapshot(login) {
|
|||||||
|
|
||||||
const latestGender = loadLatestByAliasesFromItems(items, ['gender']);
|
const latestGender = loadLatestByAliasesFromItems(items, ['gender']);
|
||||||
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
|
const gender = normalizeGenderValue(latestGender?.value || PROFILE_GENDER_UNKNOWN);
|
||||||
|
const latestAvatar = loadLatestByAliasesFromItems(items, ['ava']);
|
||||||
|
const parsedAvatar = parseArweaveAvatarValue(latestAvatar?.value || '');
|
||||||
|
const avatar = parsedAvatar.ok
|
||||||
|
? {
|
||||||
|
value: buildArweaveAvatarValue(parsedAvatar.txId),
|
||||||
|
source: 'arweave',
|
||||||
|
txId: parsedAvatar.txId,
|
||||||
|
timeMs: latestAvatar?.timeMs || 0,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
value: '',
|
||||||
|
source: '',
|
||||||
|
txId: '',
|
||||||
|
timeMs: latestAvatar?.timeMs || 0,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fields,
|
fields,
|
||||||
toggles,
|
toggles,
|
||||||
gender,
|
gender,
|
||||||
genderTimeMs: latestGender?.timeMs || 0,
|
genderTimeMs: latestGender?.timeMs || 0,
|
||||||
|
avatar,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,3 +174,11 @@ export async function saveProfileGender(login, gender) {
|
|||||||
storagePwd,
|
storagePwd,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function saveProfileAvatarArweave(login, txId) {
|
||||||
|
const cleanTxId = String(txId || '').trim();
|
||||||
|
if (!validateArweaveTxId(cleanTxId)) {
|
||||||
|
throw new Error('Некорректный Transaction ID Arweave');
|
||||||
|
}
|
||||||
|
await saveProfileParamBlock(login, 'ava', buildArweaveAvatarValue(cleanTxId));
|
||||||
|
}
|
||||||
|
|||||||
@ -242,6 +242,78 @@
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-avatar-block {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-change-btn {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview-circle {
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(155, 182, 233, 0.46);
|
||||||
|
background: rgba(13, 26, 50, 0.86);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(240, 248, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-preview-circle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wizard-preview {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wizard-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #d9e7ff;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wizard-error {
|
||||||
|
min-height: 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #f6a8b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wizard-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wizard-choice-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-param-item {
|
.profile-param-item {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user