Добавить Turbo-загрузку и превью для вложений

This commit is contained in:
AidarKC
2026-08-04 19:09:54 +04:00
parent 391b18a5ac
commit 75b75fd223
13 changed files with 1445 additions and 690 deletions
@@ -7,10 +7,20 @@ import {
validateArweaveTxId,
} from '../services/arweave-file-service.js';
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, normalizeAttachment } from '../services/attachment-format.js';
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
import {
estimateTurboUploadPrice,
formatTurboCredits,
getFreeTurboUploadBytesLimit,
getTurboBalanceForStoredSolanaKey,
topUpTurboWithSolana,
uploadFileWithTurbo,
} from '../services/turbo-upload-service.js';
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
const LAST_WALLET_KEY_PREFIX = 'shine-ui-arweave-last-wallet-v1:';
const LAST_TURBO_KEY_SOURCE_PREFIX = 'shine-ui-turbo-last-key-source-v1:';
const MAX_HISTORY_ITEMS = 100;
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
@@ -26,6 +36,26 @@ function normalizeHistoryItem(input = {}) {
};
}
function hasAttachmentPreview(item) {
return !!String(item?.preview?.ar || '').trim();
}
function isVideoAttachmentName(name) {
return getAttachmentKind({ name }) === 'video';
}
function buildHistoryPreviewMarkup(item, gateway) {
if (!hasAttachmentPreview(item)) return '';
const previewTxId = String(item.preview.ar || '').trim();
const previewUrl = buildArweaveDataUrl({ gateway, txId: previewTxId });
return `
<div class="ar-attachment-history-preview">
<img class="ar-attachment-history-preview-image" src="${escapeHtml(previewUrl)}" alt="Превью ${escapeHtml(item.name)}" loading="lazy" />
<div class="ar-attachment-history-preview-badge">Есть превью</div>
</div>
`;
}
export function readArweaveAttachmentHistory(login) {
try {
const raw = sessionStorage.getItem(HISTORY_KEY);
@@ -140,6 +170,10 @@ function lastWalletStorageKey(login) {
return `${LAST_WALLET_KEY_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
}
function lastTurboKeySourceStorageKey(login) {
return `${LAST_TURBO_KEY_SOURCE_PREFIX}${String(login || '').trim().toLowerCase() || 'anonymous'}`;
}
function readLastWalletId(login) {
try {
return sessionStorage.getItem(lastWalletStorageKey(login)) || '';
@@ -157,6 +191,23 @@ function writeLastWalletId(login, walletId) {
}
}
function readLastTurboKeySource(login, fallback = 'client') {
try {
return sessionStorage.getItem(lastTurboKeySourceStorageKey(login)) || String(fallback || 'client');
} catch {
return String(fallback || 'client');
}
}
function writeLastTurboKeySource(login, keySource) {
try {
const clean = String(keySource || '').trim() || 'client';
sessionStorage.setItem(lastTurboKeySourceStorageKey(login), clean);
} catch {
// ignore sessionStorage errors
}
}
async function resizeImageFileToAvatar(file) {
const source = file instanceof File ? file : null;
if (!source) throw new Error('Выберите изображение.');
@@ -187,6 +238,45 @@ async function resizeImageFileToAvatar(file) {
return new File([blob], 'avatar.jpg', { type: 'image/jpeg', lastModified: Date.now() });
}
async function createVideoPreviewFile(file) {
const source = file instanceof File ? file : null;
if (!source) throw new Error('Выберите видеофайл.');
const blobUrl = URL.createObjectURL(source);
try {
const video = await new Promise((resolve, reject) => {
const node = document.createElement('video');
node.preload = 'metadata';
node.muted = true;
node.playsInline = true;
node.onloadeddata = () => resolve(node);
node.onerror = () => reject(new Error('Не удалось открыть видео для создания превью.'));
node.src = blobUrl;
});
const width = Number(video.videoWidth || 0);
const height = Number(video.videoHeight || 0);
if (!width || !height) {
throw new Error('Не удалось определить размер видео для превью.');
}
const maxSide = 640;
const scale = Math.min(1, maxSide / Math.max(width, height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Canvas недоступен для создания превью.');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.86));
if (!blob) throw new Error('Не удалось собрать preview image.');
const baseName = String(source.name || 'video').replace(/\.[^.]+$/u, '') || 'video';
return new File([blob], `${baseName}-preview.jpg`, {
type: 'image/jpeg',
lastModified: Date.now(),
});
} finally {
URL.revokeObjectURL(blobUrl);
}
}
export function openArweaveAttachmentManager({
login,
storagePwd,
@@ -196,6 +286,8 @@ export function openArweaveAttachmentManager({
historyOnly = false,
mode = 'attachment',
historyPurpose = '',
uploadTransport = 'arweave',
turboKeySource = 'client',
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -208,9 +300,15 @@ export function openArweaveAttachmentManager({
let closed = false;
let wallets = [];
let turboKeyChoices = [];
let selectedWalletId = readLastWalletId(cleanLogin) || 'derived-client-key';
let selectedTurboKeySource = readLastTurboKeySource(cleanLogin, turboKeySource);
let selectedUploadTransport = String(uploadTransport || '').trim().toLowerCase() === 'turbo' ? 'turbo' : 'arweave';
let selectedFile = null;
let selectedSha256 = '';
let selectedPreviewEnabled = false;
let selectedPreviewFile = null;
let selectedPreviewSha256 = '';
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
@@ -219,6 +317,10 @@ export function openArweaveAttachmentManager({
const purposeFilter = isAvatarMode || historyPurposeMode === 'avatar' ? 'avatar' : 'attachment';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
function isTurboUpload() {
return selectedUploadTransport === 'turbo';
}
function selectedWallet() {
return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null;
}
@@ -263,22 +365,240 @@ export function openArweaveAttachmentManager({
}
};
const loadTurboKeyChoices = async (errorEl = null) => {
try {
turboKeyChoices = await getStoredSolanaWalletChoices({
login: cleanLogin,
storagePwd: cleanStoragePwd,
includeRoot: true,
});
turboKeyChoices.sort((a, b) => {
if (a?.keySource === b?.keySource) return 0;
if (a?.keySource === 'client') return -1;
if (b?.keySource === 'client') return 1;
return 0;
});
if (!turboKeyChoices.some((item) => String(item.keySource) === String(selectedTurboKeySource))) {
selectedTurboKeySource = String(
turboKeyChoices.find((item) => item.keySource === 'client')?.keySource
|| turboKeyChoices[0]?.keySource
|| 'client'
);
}
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось загрузить Solana-ключи для Turbo.');
}
};
const renderTurboMeta = (metaEl) => {
if (!metaEl) return;
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const previewSize = selectedPreviewEnabled && selectedPreviewFile ? formatBytes(selectedPreviewFile.size) : '';
const freeHint = selectedFile
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
const freeBadge = priceInfo?.isFree
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
: '';
metaEl.innerHTML = `
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>Размер файла: ${escapeHtml(fileSize)}</div>
${previewSize ? `<div>Размер превью: ${escapeHtml(previewSize)}</div>` : ''}
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
${selectedPreviewEnabled && selectedPreviewSha256 ? `<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}</div>` : ''}
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
`;
};
const refreshTurboBalance = async (metaEl, errorEl) => {
try {
balanceInfo = await getTurboBalanceForStoredSolanaKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
});
renderTurboMeta(metaEl);
if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) {
setText(errorEl, '');
}
} catch (error) {
setText(errorEl, error?.message || 'Не удалось получить Turbo balance.');
}
};
const promptTurboTopUp = async (metaEl, errorEl) => {
const value = window.prompt('Сколько SOL перевести в Turbo?', '0.01');
if (value == null) return;
setText(errorEl, 'Пополняем Turbo...');
try {
const result = await topUpTurboWithSolana({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
amountSol: value,
});
await refreshTurboBalance(metaEl, errorEl);
setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось пополнить Turbo.');
}
};
const resetPreviewState = () => {
selectedPreviewEnabled = false;
selectedPreviewFile = null;
selectedPreviewSha256 = '';
};
const isPreviewEligibleForCurrentFile = () => (
!isAvatarMode
&& selectedFile instanceof File
&& isVideoAttachmentName(selectedFile.name)
);
const combinedUploadSize = () => {
return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0);
};
const renderArweaveMeta = (metaEl) => {
if (!metaEl) return;
metaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
${selectedPreviewEnabled && selectedPreviewFile
? `<div>Превью: ${escapeHtml(selectedPreviewFile.name)} · ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>`
: ''}
${selectedPreviewEnabled && selectedPreviewSha256
? `<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}</div>`
: ''}
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
};
const recalculateArweaveState = async (metaEl, errorEl, uploadBtn) => {
uploadBtn.disabled = true;
priceInfo = null;
balanceInfo = null;
setText(errorEl, '');
if (!selectedFile || !selectedSha256) {
setText(metaEl, '');
return;
}
const wallet = selectedWallet();
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
const mainPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
let totalWinston = BigInt(mainPrice.winston);
if (selectedPreviewEnabled && selectedPreviewFile) {
const previewPrice = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedPreviewFile.size });
totalWinston += BigInt(previewPrice.winston);
}
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
priceInfo = {
ar: Number(totalWinston) / 1e12,
winston: totalWinston.toString(),
};
renderArweaveMeta(metaEl);
const hasFunds = BigInt(balanceInfo.winston) >= totalWinston;
if (!hasFunds) {
setText(errorEl, 'Недостаточно AR на выбранном кошельке.');
return;
}
uploadBtn.disabled = false;
};
const refreshTurboStateForCurrentFile = async (metaEl, errorEl, uploadBtn) => {
uploadBtn.disabled = true;
priceInfo = null;
balanceInfo = null;
setText(errorEl, '');
if (!selectedFile || !selectedSha256) {
renderTurboMeta(metaEl);
return;
}
try {
const mainPrice = await estimateTurboUploadPrice({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
byteLength: selectedFile.size,
});
let totalWinc = BigInt(mainPrice.winc);
if (selectedPreviewEnabled && selectedPreviewFile) {
const previewPrice = await estimateTurboUploadPrice({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
byteLength: selectedPreviewFile.size,
});
totalWinc += BigInt(previewPrice.winc);
}
priceInfo = {
...mainPrice,
byteLength: combinedUploadSize(),
isFree: combinedUploadSize() <= getFreeTurboUploadBytesLimit(),
winc: totalWinc.toString(),
credits: Number(totalWinc) / 1_000_000_000_000,
};
balanceInfo = await getTurboBalanceForStoredSolanaKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
});
renderTurboMeta(metaEl);
if (!priceInfo.isFree && BigInt(balanceInfo.effectiveWinc) < BigInt(priceInfo.winc)) {
setText(errorEl, 'Turbo balance недостаточен. Нажмите «Пополнить Turbo».');
return;
}
uploadBtn.disabled = false;
} catch (error) {
setText(errorEl, error?.message || 'Не удалось обновить цену Turbo.');
}
};
const showUpload = async () => {
const turboMode = isTurboUpload();
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</h3>
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
<h3 class="modal-title">${turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'))}</h3>
<div class="form-actions-grid">
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="history-top">Использовать журнал загрузок</button>'}
${isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing-top">Использовать существующий в Arweave файл</button>'}
</div>
${turboMode
? `
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Файлы до 100 KB пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
`
: `
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
`}
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
<label class="meta-muted" data-preview-option="true" hidden>
<input type="checkbox" data-preview-toggle="true" />
Добавить превью
</label>
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
@@ -287,46 +607,96 @@ export function openArweaveAttachmentManager({
`;
bindBackdrop();
const walletEl = root.querySelector('#ar-attach-wallet');
const turboKeyEl = root.querySelector('#turbo-key-source');
const fileEl = root.querySelector('#ar-attach-file');
const metaEl = root.querySelector('[data-meta="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const uploadBtn = root.querySelector('[data-action="upload"]');
const previewOptionEl = root.querySelector('[data-preview-option="true"]');
const previewToggleEl = root.querySelector('[data-preview-toggle="true"]');
await loadWallets(errorEl);
if (walletEl) {
walletEl.innerHTML = renderWalletOptions();
walletEl.value = selectedWalletId;
walletEl.addEventListener('change', () => {
selectedWalletId = String(walletEl.value || '');
writeLastWalletId(cleanLogin, selectedWalletId);
uploadBtn.disabled = true;
selectedFile = null;
selectedSha256 = '';
priceInfo = null;
balanceInfo = null;
if (fileEl) fileEl.value = '';
setText(metaEl, '');
});
root.querySelector('[data-action="switch-arweave"]')?.addEventListener('click', async () => {
if (selectedUploadTransport === 'arweave') return;
selectedUploadTransport = 'arweave';
selectedFile = null;
selectedSha256 = '';
resetPreviewState();
priceInfo = null;
balanceInfo = null;
await showUpload();
});
root.querySelector('[data-action="switch-turbo"]')?.addEventListener('click', async () => {
if (selectedUploadTransport === 'turbo') return;
selectedUploadTransport = 'turbo';
selectedFile = null;
selectedSha256 = '';
resetPreviewState();
priceInfo = null;
balanceInfo = null;
await showUpload();
});
root.querySelector('[data-action="history-top"]')?.addEventListener('click', showHistory);
root.querySelector('[data-action="existing-top"]')?.addEventListener('click', showExisting);
if (turboMode) {
await loadTurboKeyChoices(errorEl);
if (turboKeyEl) {
turboKeyEl.innerHTML = turboKeyChoices.map((item) => (
`<option value="${escapeHtml(item.keySource)}">${escapeHtml(item.label)} · ${escapeHtml(shortAddress(item.address))}</option>`
)).join('');
turboKeyEl.value = selectedTurboKeySource;
turboKeyEl.addEventListener('change', async () => {
selectedTurboKeySource = String(turboKeyEl.value || 'client');
writeLastTurboKeySource(cleanLogin, selectedTurboKeySource);
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
});
}
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
} else {
await loadWallets(errorEl);
if (walletEl) {
walletEl.innerHTML = renderWalletOptions();
walletEl.value = selectedWalletId;
walletEl.addEventListener('change', () => {
selectedWalletId = String(walletEl.value || '');
writeLastWalletId(cleanLogin, selectedWalletId);
uploadBtn.disabled = true;
selectedFile = null;
selectedSha256 = '';
resetPreviewState();
priceInfo = null;
balanceInfo = null;
if (fileEl) fileEl.value = '';
setText(metaEl, '');
});
}
}
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
root.querySelector('[data-action="existing"]')?.addEventListener('click', showExisting);
root.querySelector('[data-action="history"]')?.addEventListener('click', showHistory);
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
await promptTurboTopUp(metaEl, errorEl);
if (turboMode && selectedFile && selectedSha256) {
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
}
});
if (!turboMode) {
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
}
fileEl?.addEventListener('change', async () => {
selectedFile = fileEl.files?.[0] || null;
selectedSha256 = '';
resetPreviewState();
priceInfo = null;
balanceInfo = null;
uploadBtn.disabled = true;
setText(errorEl, '');
setText(metaEl, '');
if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false;
if (previewOptionEl) previewOptionEl.hidden = true;
if (!selectedFile) return;
try {
const wallet = selectedWallet();
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
throw new Error('Для аватара можно выбрать только изображение.');
}
@@ -335,60 +705,126 @@ export function openArweaveAttachmentManager({
}
const buffer = await selectedFile.arrayBuffer();
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
if (!hasFunds) {
setText(errorEl, 'Недостаточно AR на выбранном кошельке.');
return;
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
previewOptionEl.hidden = false;
}
if (turboMode) {
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(metaEl, errorEl, uploadBtn);
}
uploadBtn.disabled = false;
} catch (error) {
setText(errorEl, error?.message || 'Не удалось подготовить файл.');
}
});
previewToggleEl?.addEventListener('change', async () => {
if (!(previewToggleEl instanceof HTMLInputElement)) return;
if (!selectedFile || !isPreviewEligibleForCurrentFile()) {
previewToggleEl.checked = false;
return;
}
uploadBtn.disabled = true;
selectedPreviewEnabled = previewToggleEl.checked;
selectedPreviewFile = null;
selectedPreviewSha256 = '';
setText(errorEl, '');
try {
if (selectedPreviewEnabled) {
selectedPreviewFile = await createVideoPreviewFile(selectedFile);
selectedPreviewSha256 = await sha256HexFromArrayBuffer(await selectedPreviewFile.arrayBuffer());
}
if (turboMode) {
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(metaEl, errorEl, uploadBtn);
}
} catch (error) {
resetPreviewState();
previewToggleEl.checked = false;
setText(errorEl, error?.message || 'Не удалось подготовить превью.');
}
});
uploadBtn?.addEventListener('click', async () => {
if (!selectedFile || !selectedSha256) {
setText(errorEl, 'Выберите файл.');
return;
}
const wallet = selectedWallet();
if (!wallet?.jwk) {
setText(errorEl, 'Выберите Arweave-кошелёк.');
return;
if (!turboMode) {
const wallet = selectedWallet();
if (!wallet?.jwk) {
setText(errorEl, 'Выберите Arweave-кошелёк.');
return;
}
}
uploadBtn.disabled = true;
setText(errorEl, 'Загружаем файл в Arweave...');
setText(errorEl, turboMode ? 'Загружаем файл через Turbo...' : 'Загружаем файл в Arweave...');
try {
const uploaded = await uploadArweaveFile({
gateway: cleanGateway,
jwk: wallet.jwk,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
],
});
let previewUpload = null;
if (selectedPreviewEnabled && selectedPreviewFile && selectedPreviewSha256) {
previewUpload = turboMode
? await uploadFileWithTurbo({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
file: selectedPreviewFile,
shineType: 'attachment-preview',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' },
],
})
: await uploadArweaveFile({
gateway: cleanGateway,
jwk: selectedWallet()?.jwk,
file: selectedPreviewFile,
shineType: 'attachment-preview',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Attachment-Preview-For', value: selectedFile.name || 'file' },
],
});
}
const uploaded = turboMode
? await uploadFileWithTurbo({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
],
})
: await uploadArweaveFile({
gateway: cleanGateway,
jwk: selectedWallet()?.jwk,
file: selectedFile,
shineType: isAvatarMode ? 'avatar' : 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
],
});
finish(resolve, {
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
preview: previewUpload?.id && selectedPreviewSha256
? {
ar: previewUpload.id,
sha256: selectedPreviewSha256,
}
: undefined,
uploadedAtMs: Date.now(),
purpose: isAvatarMode ? 'avatar' : 'attachment',
}, { pendingPlacement: historyOnly && !isAvatarMode });
} catch (error) {
uploadBtn.disabled = false;
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
setText(errorEl, error?.message || (turboMode ? 'Не удалось загрузить файл через Turbo.' : 'Не удалось загрузить файл в Arweave.'));
}
});
@@ -407,6 +843,14 @@ export function openArweaveAttachmentManager({
<input class="input" id="ar-existing-txid" type="text" maxlength="64" placeholder="43 символа txId" />
<label class="meta-muted" for="ar-existing-name">Имя файла в сообщении</label>
<input class="input" id="ar-existing-name" type="text" maxlength="180" placeholder="например report.pdf" />
<label class="meta-muted" data-existing-preview-wrap="true" hidden>
<input type="checkbox" data-existing-preview-toggle="true" />
Добавить превью
</label>
<div class="stack" data-existing-preview-fields="true" hidden>
<label class="meta-muted" for="ar-existing-preview-txid">Transaction ID превью</label>
<input class="input" id="ar-existing-preview-txid" type="text" maxlength="64" placeholder="43 символа txId превью" />
</div>
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
@@ -419,39 +863,73 @@ export function openArweaveAttachmentManager({
bindBackdrop();
const txEl = root.querySelector('#ar-existing-txid');
const nameEl = root.querySelector('#ar-existing-name');
const previewWrapEl = root.querySelector('[data-existing-preview-wrap="true"]');
const previewToggleEl = root.querySelector('[data-existing-preview-toggle="true"]');
const previewFieldsEl = root.querySelector('[data-existing-preview-fields="true"]');
const previewTxEl = root.querySelector('#ar-existing-preview-txid');
const metaEl = root.querySelector('[data-meta="true"]');
const errorEl = root.querySelector('[data-error="true"]');
const checkBtn = root.querySelector('[data-action="check"]');
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
nameEl?.addEventListener('input', () => {
const isVideo = isVideoAttachmentName(String(nameEl.value || '').trim());
if (previewWrapEl) previewWrapEl.hidden = !isVideo;
if (!isVideo) {
if (previewToggleEl instanceof HTMLInputElement) previewToggleEl.checked = false;
if (previewFieldsEl) previewFieldsEl.hidden = true;
if (previewTxEl) previewTxEl.value = '';
}
});
previewToggleEl?.addEventListener('change', () => {
if (previewFieldsEl && previewToggleEl instanceof HTMLInputElement) {
previewFieldsEl.hidden = !previewToggleEl.checked;
}
});
checkBtn?.addEventListener('click', async () => {
const txId = String(txEl?.value || '').trim();
const name = String(nameEl?.value || '').trim() || 'arweave-file';
const wantsPreview = previewToggleEl instanceof HTMLInputElement && previewToggleEl.checked;
const previewTxId = String(previewTxEl?.value || '').trim();
if (!validateArweaveTxId(txId)) {
setText(errorEl, 'Некорректный Transaction ID Arweave.');
return;
}
if (wantsPreview && !validateArweaveTxId(previewTxId)) {
setText(errorEl, 'Некорректный Transaction ID превью.');
return;
}
checkBtn.disabled = true;
setText(errorEl, 'Скачиваем файл и считаем SHA-256...');
setText(metaEl, '');
try {
const meta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId });
let preview = null;
if (wantsPreview) {
const previewMeta = await loadArweaveFileMetadata({ gateway: cleanGateway, txId: previewTxId });
preview = {
ar: previewTxId,
sha256: previewMeta.sha256Hex,
};
}
const item = normalizeAttachment({
name,
size: meta.sizeBytes,
sha256: meta.sha256Hex,
ar: txId,
preview,
uploadedAtMs: Date.now(),
});
metaEl.innerHTML = `
<div>Размер: ${escapeHtml(formatBytes(item.size))}</div>
<div>SHA-256: ${escapeHtml(item.sha256)}</div>
${item.preview ? '<div>Превью будет добавлено вместе с файлом.</div>' : ''}
`;
setText(errorEl, '');
const addBtn = document.createElement('button');
addBtn.className = 'primary-btn';
addBtn.type = 'button';
addBtn.textContent = 'Добавить в сообщение';
addBtn.addEventListener('click', () => finish(resolve, item));
addBtn.textContent = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение';
addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode }));
metaEl.append(addBtn);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.');
@@ -475,11 +953,13 @@ export function openArweaveAttachmentManager({
${rows.length ? rows.map((item, index) => {
const isSelected = selectedTxIdSet.has(item.ar);
return `
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
<article class="ar-attachment-history-tile${hasAttachmentPreview(item) ? ' ar-attachment-history-tile--with-preview' : ''}${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
${buildHistoryPreviewMarkup(item, cleanGateway)}
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}${item.purpose === 'avatar' ? ' · аватар' : ''}</strong>
<div class="ar-attachment-history-meta-row">
<span>${escapeHtml(formatBytes(item.size))}</span>
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
</div>