SHA256
1054 lines
48 KiB
JavaScript
1054 lines
48 KiB
JavaScript
import {
|
||
buildArweaveDataUrl,
|
||
getArweaveUploadPrice,
|
||
loadArweaveFileMetadata,
|
||
sha256HexFromArrayBuffer,
|
||
uploadArweaveFile,
|
||
validateArweaveTxId,
|
||
} from '../services/arweave-file-service.js';
|
||
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.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;
|
||
|
||
function normalizeHistoryItem(input = {}) {
|
||
const item = normalizeAttachment(input);
|
||
const placedInShineAtMs = Number(input.placedInShineAtMs || 0) || 0;
|
||
const purpose = String(input.purpose || '').trim() === 'avatar' ? 'avatar' : 'attachment';
|
||
return {
|
||
...item,
|
||
purpose,
|
||
pendingPlacement: input.pendingPlacement === true && !placedInShineAtMs,
|
||
placedInShineAtMs,
|
||
};
|
||
}
|
||
|
||
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);
|
||
const parsed = raw ? JSON.parse(raw) : {};
|
||
const key = String(login || '').trim().toLowerCase();
|
||
const rows = Array.isArray(parsed?.[key]) ? parsed[key] : [];
|
||
return rows.map((item) => normalizeHistoryItem(item)).filter(Boolean);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function saveArweaveAttachmentHistory(login, rows) {
|
||
try {
|
||
const key = String(login || '').trim().toLowerCase();
|
||
const raw = sessionStorage.getItem(HISTORY_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : {};
|
||
parsed[key] = (Array.isArray(rows) ? rows : [])
|
||
.map((item) => normalizeHistoryItem(item))
|
||
.slice(0, MAX_HISTORY_ITEMS);
|
||
sessionStorage.setItem(HISTORY_KEY, JSON.stringify(parsed));
|
||
} catch {
|
||
// ignore sessionStorage errors
|
||
}
|
||
}
|
||
|
||
export function clearArweaveAttachmentHistory(login) {
|
||
saveArweaveAttachmentHistory(login, []);
|
||
}
|
||
|
||
export function addArweaveAttachmentToHistory(login, attachment, { pendingPlacement = undefined, markPlaced = false } = {}) {
|
||
const item = normalizeHistoryItem(attachment);
|
||
const rows = readArweaveAttachmentHistory(login);
|
||
const existing = rows.find((row) => row.ar === item.ar) || {};
|
||
const placedInShineAtMs = markPlaced
|
||
? Date.now()
|
||
: Number(existing.placedInShineAtMs || item.placedInShineAtMs || 0) || 0;
|
||
const nextPendingPlacement = pendingPlacement === undefined
|
||
? (existing.pendingPlacement === true && !placedInShineAtMs)
|
||
: (!!pendingPlacement && !placedInShineAtMs);
|
||
const nextItem = {
|
||
...item,
|
||
pendingPlacement: nextPendingPlacement,
|
||
placedInShineAtMs,
|
||
};
|
||
const nextRows = rows.filter((row) => row.ar !== item.ar);
|
||
nextRows.unshift(nextItem);
|
||
saveArweaveAttachmentHistory(login, nextRows);
|
||
return nextItem;
|
||
}
|
||
|
||
export function markArweaveAttachmentPlaced(login, attachment) {
|
||
const item = normalizeHistoryItem(attachment);
|
||
const rows = readArweaveAttachmentHistory(login);
|
||
const existing = rows.find((row) => row.ar === item.ar) || {};
|
||
const nextItem = {
|
||
...item,
|
||
uploadedAtMs: item.uploadedAtMs || existing.uploadedAtMs || Date.now(),
|
||
pendingPlacement: false,
|
||
placedInShineAtMs: Number(existing.placedInShineAtMs || 0) || Date.now(),
|
||
};
|
||
const nextRows = rows.filter((row) => row.ar !== item.ar);
|
||
nextRows.unshift(nextItem);
|
||
saveArweaveAttachmentHistory(login, nextRows);
|
||
return nextItem;
|
||
}
|
||
|
||
export function formatArweaveHistoryTime(ms) {
|
||
const value = Number(ms || 0);
|
||
if (!Number.isFinite(value) || value <= 0) return '—';
|
||
const diffMs = Date.now() - value;
|
||
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
|
||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||
return `${minutes} мин назад`;
|
||
}
|
||
return new Date(value).toLocaleString('ru-RU');
|
||
}
|
||
|
||
export function formatArweaveHistoryDateTime(ms) {
|
||
const value = Number(ms || 0);
|
||
if (!Number.isFinite(value) || value <= 0) return '—';
|
||
return new Date(value).toLocaleString('ru-RU');
|
||
}
|
||
|
||
export async function getArweaveAttachmentAvailability({ gateway, attachment } = {}) {
|
||
const item = normalizeAttachment(attachment);
|
||
const url = buildArweaveDataUrl({ gateway, txId: item.ar });
|
||
try {
|
||
const response = await fetch(url, { method: 'HEAD', cache: 'no-store' });
|
||
if (response.ok) return { status: 'available', label: '✓ Доступен', url };
|
||
} catch {
|
||
// gateway может временно не отвечать или блокировать HEAD; для UI это всё равно недоступно.
|
||
}
|
||
const ageMs = Date.now() - Number(item.uploadedAtMs || 0);
|
||
if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs < RECENT_UPLOAD_MS) {
|
||
return { status: 'pending', label: '… Ещё обновляется', url };
|
||
}
|
||
return { status: 'unavailable', label: '✕ Недоступен', url };
|
||
}
|
||
|
||
function setText(node, text) {
|
||
if (node) node.textContent = String(text || '');
|
||
}
|
||
|
||
function shortAddress(value) {
|
||
const raw = String(value || '').trim();
|
||
if (raw.length <= 16) return raw;
|
||
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
|
||
}
|
||
|
||
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)) || '';
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function writeLastWalletId(login, walletId) {
|
||
try {
|
||
const clean = String(walletId || '').trim();
|
||
if (clean) sessionStorage.setItem(lastWalletStorageKey(login), clean);
|
||
} catch {
|
||
// Если sessionStorage недоступен, выбор сохраняется хотя бы в текущем окне менеджера.
|
||
}
|
||
}
|
||
|
||
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('Выберите изображение.');
|
||
const dataUrl = await new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result || ''));
|
||
reader.onerror = () => reject(new Error('Не удалось прочитать изображение.'));
|
||
reader.readAsDataURL(source);
|
||
});
|
||
const image = await new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => resolve(img);
|
||
img.onerror = () => reject(new Error('Не удалось открыть изображение.'));
|
||
img.src = dataUrl;
|
||
});
|
||
const side = Math.min(image.naturalWidth || image.width, image.naturalHeight || image.height);
|
||
if (!side || side <= 0) throw new Error('Некорректный размер изображения.');
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 512;
|
||
canvas.height = 512;
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) throw new Error('Canvas недоступен.');
|
||
const sx = Math.max(0, ((image.naturalWidth || image.width) - side) / 2);
|
||
const sy = Math.max(0, ((image.naturalHeight || image.height) - side) / 2);
|
||
ctx.drawImage(image, sx, sy, side, side, 0, 0, 512, 512);
|
||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9));
|
||
if (!blob) throw new Error('Не удалось сжать аватар.');
|
||
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,
|
||
gateway,
|
||
onSelect,
|
||
selectedTxIds = [],
|
||
historyOnly = false,
|
||
mode = 'attachment',
|
||
historyPurpose = '',
|
||
uploadTransport = 'arweave',
|
||
turboKeySource = 'client',
|
||
} = {}) {
|
||
const cleanLogin = String(login || '').trim();
|
||
const cleanStoragePwd = String(storagePwd || '').trim();
|
||
const cleanGateway = String(gateway || 'https://arweave.net').trim();
|
||
if (!cleanLogin || !cleanStoragePwd) return Promise.reject(new Error('Нет активной сессии.'));
|
||
|
||
const root = document.createElement('div');
|
||
root.className = 'ar-attachment-manager-root';
|
||
document.body.append(root);
|
||
|
||
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;
|
||
const isAvatarMode = String(mode || '') === 'avatar';
|
||
const historyPurposeMode = String(historyPurpose || '').trim();
|
||
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;
|
||
}
|
||
|
||
function close(resolve, result = null) {
|
||
if (closed) return;
|
||
closed = true;
|
||
root.remove();
|
||
resolve(result);
|
||
}
|
||
|
||
function finish(resolve, attachment, { pendingPlacement = undefined } = {}) {
|
||
const item = addArweaveAttachmentToHistory(cleanLogin, attachment, {
|
||
pendingPlacement,
|
||
markPlaced: false,
|
||
});
|
||
if (!pendingPlacement && typeof onSelect === 'function') onSelect(item);
|
||
close(resolve, item);
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const bindBackdrop = () => {
|
||
const modal = root.querySelector('[data-ar-attach-modal="true"]');
|
||
modal?.addEventListener('click', (event) => {
|
||
if (event.target === modal) close(resolve, null);
|
||
});
|
||
};
|
||
|
||
const renderWalletOptions = () => wallets.map((wallet) => (
|
||
`<option value="${escapeHtml(wallet.id)}">${escapeHtml(wallet.label)} · ${escapeHtml(shortAddress(wallet.address))}</option>`
|
||
)).join('');
|
||
|
||
const loadWallets = async (errorEl = null) => {
|
||
try {
|
||
wallets = await getArweaveWalletChoices({ login: cleanLogin, storagePwd: cleanStoragePwd });
|
||
if (!wallets.some((item) => String(item.id) === String(selectedWalletId))) {
|
||
selectedWalletId = String(wallets[0]?.id || 'derived-client-key');
|
||
}
|
||
writeLastWalletId(cleanLogin, selectedWalletId);
|
||
} catch (error) {
|
||
setText(errorEl, error?.message || 'Не удалось загрузить Arweave-кошельки.');
|
||
}
|
||
};
|
||
|
||
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">${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">
|
||
${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>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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"]');
|
||
|
||
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="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 {
|
||
if (isAvatarMode && !String(selectedFile.type || '').startsWith('image/')) {
|
||
throw new Error('Для аватара можно выбрать только изображение.');
|
||
}
|
||
if (isAvatarMode) {
|
||
selectedFile = await resizeImageFileToAvatar(selectedFile);
|
||
}
|
||
const buffer = await selectedFile.arrayBuffer();
|
||
selectedSha256 = await sha256HexFromArrayBuffer(buffer);
|
||
if (previewOptionEl && isPreviewEligibleForCurrentFile()) {
|
||
previewOptionEl.hidden = false;
|
||
}
|
||
if (turboMode) {
|
||
await refreshTurboStateForCurrentFile(metaEl, errorEl, uploadBtn);
|
||
} else {
|
||
await recalculateArweaveState(metaEl, errorEl, uploadBtn);
|
||
}
|
||
} 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;
|
||
}
|
||
if (!turboMode) {
|
||
const wallet = selectedWallet();
|
||
if (!wallet?.jwk) {
|
||
setText(errorEl, 'Выберите Arweave-кошелёк.');
|
||
return;
|
||
}
|
||
}
|
||
uploadBtn.disabled = true;
|
||
setText(errorEl, turboMode ? 'Загружаем файл через Turbo...' : 'Загружаем файл в Arweave...');
|
||
try {
|
||
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 || (turboMode ? 'Не удалось загрузить файл через Turbo.' : 'Не удалось загрузить файл в Arweave.'));
|
||
}
|
||
});
|
||
|
||
if (!autoOpenedFileDialog) {
|
||
autoOpenedFileDialog = true;
|
||
window.setTimeout(() => fileEl?.click(), 0);
|
||
}
|
||
};
|
||
|
||
const showExisting = () => {
|
||
root.innerHTML = `
|
||
<div class="modal" data-ar-attach-modal="true">
|
||
<div class="modal-card stack ar-attachment-manager-card">
|
||
<h3 class="modal-title">Существующий файл Arweave</h3>
|
||
<label class="meta-muted" for="ar-existing-txid">Transaction ID</label>
|
||
<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">
|
||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||
<button class="primary-btn" type="button" data-action="check">Скачать и проверить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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 = historyOnly ? 'Добавить в журнал' : 'Добавить в сообщение';
|
||
addBtn.addEventListener('click', () => finish(resolve, item, { pendingPlacement: historyOnly && !isAvatarMode }));
|
||
metaEl.append(addBtn);
|
||
} catch (error) {
|
||
setText(errorEl, error?.message || 'Не удалось проверить файл Arweave.');
|
||
checkBtn.disabled = false;
|
||
}
|
||
});
|
||
window.setTimeout(() => txEl?.focus(), 0);
|
||
};
|
||
|
||
const showHistory = () => {
|
||
const rows = readArweaveAttachmentHistory(cleanLogin)
|
||
.filter((item) => purposeFilter === 'avatar' ? item.purpose === 'avatar' : item.purpose !== 'avatar')
|
||
.slice()
|
||
.reverse();
|
||
root.innerHTML = `
|
||
<div class="modal" data-ar-attach-modal="true">
|
||
<div class="modal-card stack ar-attachment-manager-card ar-attachment-manager-card--wide">
|
||
<h3 class="modal-title">Журнал загруженных файлов</h3>
|
||
<p class="meta-muted">Журнал хранится только в этой браузерной сессии.</p>
|
||
<div class="ar-attachment-history-tiles" data-history-scroll="true">
|
||
${rows.length ? rows.map((item, index) => {
|
||
const isSelected = selectedTxIdSet.has(item.ar);
|
||
return `
|
||
<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>
|
||
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
|
||
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
|
||
</article>
|
||
`;
|
||
}).join('') : '<div class="card meta-muted">В этой сессии ещё нет загруженных файлов.</div>'}
|
||
</div>
|
||
<div class="form-actions-grid">
|
||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
bindBackdrop();
|
||
const scrollEl = root.querySelector('[data-history-scroll="true"]');
|
||
window.setTimeout(() => {
|
||
if (scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
|
||
}, 0);
|
||
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
|
||
root.querySelectorAll('[data-pick]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const index = Number(button.getAttribute('data-pick'));
|
||
const item = rows[index];
|
||
if (item) finish(resolve, item);
|
||
});
|
||
});
|
||
rows.forEach((item, index) => {
|
||
const statusEl = root.querySelector(`[data-status="${index}"]`);
|
||
void getArweaveAttachmentAvailability({ gateway: cleanGateway, attachment: item })
|
||
.then((result) => {
|
||
if (!statusEl) return;
|
||
statusEl.className = `ar-attachment-status ar-attachment-status--${result.status}`;
|
||
statusEl.textContent = result.label;
|
||
})
|
||
.catch(() => {
|
||
if (!statusEl) return;
|
||
statusEl.className = 'ar-attachment-status ar-attachment-status--unavailable';
|
||
statusEl.textContent = 'Недоступен';
|
||
});
|
||
});
|
||
};
|
||
|
||
const showAddWallet = () => {
|
||
root.innerHTML = `
|
||
<div class="modal" data-ar-attach-modal="true">
|
||
<div class="modal-card stack ar-attachment-manager-card">
|
||
<h3 class="modal-title">Добавить Arweave-кошелёк</h3>
|
||
<p class="meta-muted">Вставьте JSON JWK или короткую строку base64url(JSON JWK). Ключ будет сохранён в зашифрованном контейнере ключей этого пользователя на устройстве.</p>
|
||
<label class="meta-muted" for="ar-wallet-label">Название</label>
|
||
<input class="input" id="ar-wallet-label" type="text" maxlength="80" placeholder="например Основной AR" />
|
||
<label class="meta-muted" for="ar-wallet-secret">Секрет Arweave</label>
|
||
<textarea class="input" id="ar-wallet-secret" rows="7" placeholder="JSON JWK или base64url(JSON)"></textarea>
|
||
<p class="meta-muted inline-error" data-error="true"></p>
|
||
<div class="form-actions-grid">
|
||
<button class="secondary-btn" type="button" data-action="back">Назад</button>
|
||
<button class="primary-btn" type="button" data-action="save">Добавить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
bindBackdrop();
|
||
const labelEl = root.querySelector('#ar-wallet-label');
|
||
const secretEl = root.querySelector('#ar-wallet-secret');
|
||
const errorEl = root.querySelector('[data-error="true"]');
|
||
const saveBtn = root.querySelector('[data-action="save"]');
|
||
root.querySelector('[data-action="back"]')?.addEventListener('click', showUpload);
|
||
saveBtn?.addEventListener('click', async () => {
|
||
saveBtn.disabled = true;
|
||
setText(errorEl, '');
|
||
try {
|
||
const wallet = await addArweaveWalletSecret({
|
||
login: cleanLogin,
|
||
storagePwd: cleanStoragePwd,
|
||
label: String(labelEl?.value || '').trim(),
|
||
secret: String(secretEl?.value || '').trim(),
|
||
});
|
||
selectedWalletId = wallet.id;
|
||
writeLastWalletId(cleanLogin, selectedWalletId);
|
||
await showUpload();
|
||
} catch (error) {
|
||
saveBtn.disabled = false;
|
||
setText(errorEl, error?.message || 'Не удалось добавить Arweave-кошелёк.');
|
||
}
|
||
});
|
||
};
|
||
|
||
void showUpload();
|
||
});
|
||
}
|