Files
SHiNE-server/shine-UI/js/components/arweave-attachment-manager.js
T

1076 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 selectedPreviewPriceInfo = null;
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 = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) 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 freeHint = selectedFile
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
const freeBadge = priceInfo?.isFree
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
: '';
mainMetaEl.innerHTML = `
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>Размер файла: ${escapeHtml(fileSize)}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
`;
previewMetaEl.innerHTML = '';
if (selectedPreviewEnabled && selectedPreviewFile) {
previewMetaEl.innerHTML = `
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
`;
}
};
const refreshTurboBalance = async (mainMetaEl, previewMetaEl, errorEl) => {
try {
balanceInfo = await getTurboBalanceForStoredSolanaKey({
login: cleanLogin,
storagePwd: cleanStoragePwd,
keySource: selectedTurboKeySource,
});
renderTurboMeta(mainMetaEl, previewMetaEl);
if (errorEl && String(errorEl.textContent || '').includes('Turbo balance')) {
setText(errorEl, '');
}
} catch (error) {
setText(errorEl, error?.message || 'Не удалось получить Turbo balance.');
}
};
const promptTurboTopUp = async (mainMetaEl, previewMetaEl, 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(mainMetaEl, previewMetaEl, errorEl);
setText(errorEl, `Turbo пополнен. Tx: ${result.txId || 'отправлено'}`);
} catch (error) {
setText(errorEl, error?.message || 'Не удалось пополнить Turbo.');
}
};
const resetPreviewState = () => {
selectedPreviewEnabled = false;
selectedPreviewFile = null;
selectedPreviewSha256 = '';
selectedPreviewPriceInfo = null;
};
const isPreviewEligibleForCurrentFile = () => (
!isAvatarMode
&& selectedFile instanceof File
&& isVideoAttachmentName(selectedFile.name)
);
const combinedUploadSize = () => {
return Number(selectedFile?.size || 0) + Number(selectedPreviewEnabled ? (selectedPreviewFile?.size || 0) : 0);
};
const renderArweaveMeta = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) return;
mainMetaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</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>
`;
previewMetaEl.innerHTML = '';
if (selectedPreviewEnabled && selectedPreviewFile) {
previewMetaEl.innerHTML = `
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}</div>
`;
}
};
const recalculateArweaveState = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => {
uploadBtn.disabled = true;
priceInfo = null;
balanceInfo = null;
selectedPreviewPriceInfo = null;
setText(errorEl, '');
if (!selectedFile || !selectedSha256) {
setText(mainMetaEl, '');
setText(previewMetaEl, '');
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 });
selectedPreviewPriceInfo = previewPrice;
totalWinston += BigInt(previewPrice.winston);
}
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
priceInfo = {
ar: Number(totalWinston) / 1e12,
winston: totalWinston.toString(),
};
renderArweaveMeta(mainMetaEl, previewMetaEl);
const hasFunds = BigInt(balanceInfo.winston) >= totalWinston;
if (!hasFunds) {
setText(errorEl, 'Недостаточно AR на выбранном кошельке.');
return;
}
uploadBtn.disabled = false;
};
const refreshTurboStateForCurrentFile = async (mainMetaEl, previewMetaEl, errorEl, uploadBtn) => {
uploadBtn.disabled = true;
priceInfo = null;
balanceInfo = null;
selectedPreviewPriceInfo = null;
setText(errorEl, '');
if (!selectedFile || !selectedSha256) {
renderTurboMeta(mainMetaEl, previewMetaEl);
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,
});
selectedPreviewPriceInfo = previewPrice;
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(mainMetaEl, previewMetaEl);
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/*"' : ''} />
<div class="ar-attachment-meta" data-meta-main="true"></div>
<label class="meta-muted" data-preview-option="true" hidden>
<input type="checkbox" data-preview-toggle="true" />
Добавить превью
</label>
<div class="ar-attachment-meta" data-meta-preview="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 mainMetaEl = root.querySelector('[data-meta-main="true"]');
const previewMetaEl = root.querySelector('[data-meta-preview="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(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
});
}
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, 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(mainMetaEl, '');
setText(previewMetaEl, '');
});
}
}
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
if (turboMode && selectedFile && selectedSha256) {
await refreshTurboStateForCurrentFile(mainMetaEl, previewMetaEl, 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(mainMetaEl, '');
setText(previewMetaEl, '');
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(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(mainMetaEl, previewMetaEl, 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(mainMetaEl, previewMetaEl, errorEl, uploadBtn);
} else {
await recalculateArweaveState(mainMetaEl, previewMetaEl, 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();
});
}