SHA256
Добавить временную бесплатную загрузку аватаров в Arweave
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { authService } from '../state.js';
|
||||
import { getArweaveBalance, getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
|
||||
import {
|
||||
buildArweaveDataUrl,
|
||||
@@ -9,8 +10,11 @@ import {
|
||||
validateSha256Hex,
|
||||
validateAvatarSourceFile,
|
||||
} from '../services/arweave-file-service.js';
|
||||
import { bytesToBase64 } from '../services/crypto-utils.js';
|
||||
import { saveProfileAvatarArweave } from '../services/user-profile-params.js';
|
||||
|
||||
const DEFAULT_FREE_AVATAR_MAX_BYTES = 128 * 1024;
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
@@ -72,6 +76,8 @@ export function openAvatarWizard({
|
||||
let priceInfo = null;
|
||||
let uploadedTxId = '';
|
||||
let uploadedSha256Hex = '';
|
||||
let uploadedInfoText = '';
|
||||
let freeQuotaInfo = null;
|
||||
|
||||
function revokePreviewUrl() {
|
||||
if (!lastPreviewUrl) return;
|
||||
@@ -105,10 +111,11 @@ export function openAvatarWizard({
|
||||
<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>
|
||||
<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="primary-btn" type="button" data-action="upload-free">Залить аватар бесплатно</button>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -121,6 +128,7 @@ export function openAvatarWizard({
|
||||
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(); });
|
||||
root.querySelector('[data-action="upload-free"]')?.addEventListener('click', () => { void showStepFreeUpload(); });
|
||||
};
|
||||
|
||||
const showStepExistingInput = () => {
|
||||
@@ -339,6 +347,7 @@ export function openAvatarWizard({
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
try {
|
||||
uploadedInfoText = '';
|
||||
const uploaded = await uploadArweaveFile({
|
||||
gateway: cleanGateway,
|
||||
jwk: walletCtx?.jwk,
|
||||
@@ -363,6 +372,169 @@ export function openAvatarWizard({
|
||||
});
|
||||
};
|
||||
|
||||
const showStepFreeLimitExhausted = () => {
|
||||
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">Вы исчерпали бесплатный лимит аватарок.</p>
|
||||
<div class="avatar-wizard-actions">
|
||||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||||
<button class="secondary-btn" type="button" data-action="close">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
root.querySelector('[data-action="back"]')?.addEventListener('click', showStepChoice);
|
||||
root.querySelector('[data-action="close"]')?.addEventListener('click', () => close(false, resolve));
|
||||
};
|
||||
|
||||
const showStepFreeUploadForm = () => {
|
||||
if (closed) return;
|
||||
const remaining = Number(freeQuotaInfo?.remainingCount || 0);
|
||||
const limit = Number(freeQuotaInfo?.limit || 3);
|
||||
const maxBytes = Number(freeQuotaInfo?.maxBytes || DEFAULT_FREE_AVATAR_MAX_BYTES);
|
||||
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">Осталось бесплатных загрузок: ${remaining} из ${limit}.</p>
|
||||
<label class="meta-muted" for="avatar-free-file-input">Выберите изображение</label>
|
||||
<input class="input" id="avatar-free-file-input" type="file" accept="image/jpeg,image/png,image/webp" />
|
||||
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед отправкой изображение уменьшается для аватарки. Итоговый файл должен быть не больше ${formatBytes(maxBytes)}.</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">Это временная тестовая бесплатная загрузка через серверный кошелёк 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>Залить бесплатно</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = root.querySelector('[data-avatar-wizard-modal="true"]');
|
||||
const fileInput = root.querySelector('#avatar-free-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"]');
|
||||
|
||||
optimized = 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();
|
||||
|
||||
const selectedFile = fileInput.files?.[0] || null;
|
||||
if (!selectedFile) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
validateAvatarSourceFile(selectedFile);
|
||||
optimized = await prepareAvatarImageFile(selectedFile);
|
||||
if (Number(optimized?.file?.size || 0) > maxBytes) {
|
||||
throw new Error(`После уменьшения файл всё ещё больше ${formatBytes(maxBytes)}. Возьмите более простое изображение.`);
|
||||
}
|
||||
|
||||
lastPreviewUrl = URL.createObjectURL(optimized.file);
|
||||
previewImage.src = lastPreviewUrl;
|
||||
previewWrap.hidden = false;
|
||||
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>
|
||||
`;
|
||||
uploadBtn.disabled = false;
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, String(error?.message || 'Не удалось подготовить изображение.'));
|
||||
}
|
||||
});
|
||||
|
||||
uploadBtn?.addEventListener('click', async () => {
|
||||
setNodeText(errorEl, '');
|
||||
if (!optimized?.file) {
|
||||
setNodeText(errorEl, 'Выберите файл изображения.');
|
||||
return;
|
||||
}
|
||||
|
||||
uploadBtn.disabled = true;
|
||||
try {
|
||||
const fileBytes = new Uint8Array(await optimized.file.arrayBuffer());
|
||||
const uploaded = await authService.uploadTestFreeAvatar({
|
||||
contentType: optimized.contentType,
|
||||
fileBytesBase64: bytesToBase64(fileBytes),
|
||||
sha256Hex: String(optimized.sha256Hex || '').trim().toLowerCase(),
|
||||
});
|
||||
uploadedTxId = String(uploaded.txId || '').trim();
|
||||
uploadedSha256Hex = String(uploaded.sha256Hex || optimized.sha256Hex || '').trim().toLowerCase();
|
||||
uploadedInfoText = `Осталось бесплатных загрузок: ${Number(uploaded.remainingCount || 0)} из ${Number(uploaded.limit || limit)}.`;
|
||||
if (!uploadedTxId) {
|
||||
throw new Error('Сервер не вернул Transaction ID.');
|
||||
}
|
||||
showStepUploaded();
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, String(error?.message || 'Не удалось бесплатно загрузить аватар.'));
|
||||
uploadBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showStepFreeUpload = 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">Проверяем остаток бесплатных загрузок...</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 {
|
||||
freeQuotaInfo = await authService.getTestFreeAvatarQuota();
|
||||
} catch (error) {
|
||||
setNodeText(errorEl, error?.message || 'Не удалось получить остаток бесплатных загрузок.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!freeQuotaInfo?.enabled) {
|
||||
setNodeText(loadingEl, '');
|
||||
setNodeText(errorEl, 'Временная бесплатная загрузка аватаров сейчас отключена на сервере.');
|
||||
return;
|
||||
}
|
||||
if (Number(freeQuotaInfo?.remainingCount || 0) <= 0) {
|
||||
showStepFreeLimitExhausted();
|
||||
return;
|
||||
}
|
||||
showStepFreeUploadForm();
|
||||
};
|
||||
|
||||
const showStepUploaded = () => {
|
||||
if (closed) return;
|
||||
root.innerHTML = `
|
||||
@@ -373,6 +545,7 @@ export function openAvatarWizard({
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedTxId)}</p>
|
||||
<p class="meta-muted">SHA-256:</p>
|
||||
<p class="avatar-wizard-meta">${escapeHtml(uploadedSha256Hex)}</p>
|
||||
${uploadedInfoText ? `<p class="meta-muted">${escapeHtml(uploadedInfoText)}</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>
|
||||
|
||||
@@ -599,8 +599,6 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
async function onChangeAvatarClick() {
|
||||
const confirmed = window.confirm('Сменить аватар?');
|
||||
if (!confirmed) return;
|
||||
status.className = 'status-line';
|
||||
status.textContent = 'Открываем мастер аватара...';
|
||||
|
||||
|
||||
@@ -2208,6 +2208,22 @@ export class AuthService {
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async getTestFreeAvatarQuota() {
|
||||
const response = await this.ws.request('TestGetFreeAvatarQuota', {});
|
||||
if (response.status !== 200) throw opError('TestGetFreeAvatarQuota', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async uploadTestFreeAvatar({ contentType, fileBytesBase64, sha256Hex }) {
|
||||
const response = await this.ws.request('TestUploadFreeAvatar', {
|
||||
contentType,
|
||||
fileBytesBase64,
|
||||
sha256Hex,
|
||||
}, 60000);
|
||||
if (response.status !== 200) throw opError('TestUploadFreeAvatar', response);
|
||||
return response.payload || {};
|
||||
}
|
||||
|
||||
async setUserRelation({ login, toLogin, kind, enabled, storagePwd }) {
|
||||
const cleanKind = String(kind || '').trim().toLowerCase();
|
||||
const kinds = CONNECTION_SUBTYPES[cleanKind];
|
||||
|
||||
Reference in New Issue
Block a user