From 75b75fd22323537d85a2deb65251a86ab9af05ee393e3a3a481756df1af55cc5 Mon Sep 17 00:00:00 2001 From: AidarKC Date: Tue, 4 Aug 2026 19:09:54 +0400 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20Turbo-=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D1=83?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API/04_Add_Block_to_Blockchain_API.md | 8 +- docs/Blockchain/11_TEXT_Blocks.md | 13 +- docs/Blockchain/15_TEXT_Attachments.md | 35 +- docs/Blockchain/CHANGELOG.md | 6 + docs/Blockchain/README.md | 2 +- .../components/arweave-attachment-manager.js | 602 +++++++++++++++-- shine-UI/js/components/avatar-wizard.js | 636 +----------------- shine-UI/js/pages/arweave-uploads-view.js | 18 +- shine-UI/js/services/attachment-format.js | 36 +- shine-UI/js/services/solana-wallet-service.js | 108 ++- shine-UI/js/services/turbo-upload-service.js | 213 ++++++ shine-UI/js/vendor/turbo-sdk.web.bundle.js | 423 ++++++++++++ shine-UI/styles/components.css | 35 + 13 files changed, 1445 insertions(+), 690 deletions(-) create mode 100644 shine-UI/js/services/turbo-upload-service.js create mode 100644 shine-UI/js/vendor/turbo-sdk.web.bundle.js diff --git a/docs/API/04_Add_Block_to_Blockchain_API.md b/docs/API/04_Add_Block_to_Blockchain_API.md index d3811536..c156605f 100644 --- a/docs/API/04_Add_Block_to_Blockchain_API.md +++ b/docs/API/04_Add_Block_to_Blockchain_API.md @@ -141,7 +141,7 @@ ### Вложения в сообщениях -Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1`. +Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` вложения записываются в начало текста сообщения одним или несколькими тегами `SHiNE:attach v=1` или `v=2`. Пример текстового содержимого body: @@ -151,6 +151,12 @@ Текст сообщения ``` +Пример вложения с отдельным preview-файлом: + +```text + +``` + Сервер хранит это как обычный `TEXT`-блок. Отображение карусели, картинок, видео и карточек файлов делает клиент. Полная спецификация тега находится в `docs/Blockchain/15_TEXT_Attachments.md`. ### Создание публичного канала с профилем diff --git a/docs/Blockchain/11_TEXT_Blocks.md b/docs/Blockchain/11_TEXT_Blocks.md index 6c4dca05..63d68d65 100644 --- a/docs/Blockchain/11_TEXT_Blocks.md +++ b/docs/Blockchain/11_TEXT_Blocks.md @@ -45,7 +45,7 @@ TEXT-тип хранит сообщения и редактирования. - Такой edit трактуется как логическое удаление содержимого сообщения. - Для удаления используется именно edit-блок; отдельного `DELETE`-подтипа нет. -## Вложения в текстовых сообщениях (`SHiNE:attach v=1`) +## Вложения в текстовых сообщениях (`SHiNE:attach v=1/v=2`) Для `TEXT_POST`, `TEXT_REPLY`, `TEXT_EDIT_POST` и `TEXT_EDIT_REPLY` клиент может хранить вложения как технические строки в начале обычного `text`. @@ -59,15 +59,22 @@ TEXT-тип хранит сообщения и редактирования. ``` +Для файлов с отдельным превью клиент может использовать расширенный вариант: + +```text + +``` + Несколько вложений идут подряд в самом начале текста, по одному блоку на строку. После последнего блока идёт обычный пользовательский текст. Обязательные поля: -- `v=1` +- `v=1` или `v=2` - `name` - имя файла, закодированное через `encodeURIComponent` - `size` - размер файла в байтах - `sha256` - SHA-256 исходного файла в hex - `ar` - короткий Arweave Transaction ID, без полного URL +- для `v=2` дополнительно могут присутствовать `previewAr` и `previewSha256` Правила клиента: @@ -76,4 +83,4 @@ TEXT-тип хранит сообщения и редактирования. - старые клиенты без поддержки вложений могут показывать технические строки как обычный текст; - пустой пользовательский текст допустим, если перед ним есть хотя бы один валидный attach-блок. -Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер и SHA-256. +Файлы хранятся вне блокчейна, в Arweave. В блокчейне остаются только `txId`, имя, размер, SHA-256 и, при наличии отдельного preview-файла, `previewAr/previewSha256`. diff --git a/docs/Blockchain/15_TEXT_Attachments.md b/docs/Blockchain/15_TEXT_Attachments.md index ea8050b9..9500a5c3 100644 --- a/docs/Blockchain/15_TEXT_Attachments.md +++ b/docs/Blockchain/15_TEXT_Attachments.md @@ -1,4 +1,4 @@ -# Вложения в TEXT-сообщениях (`SHiNE:attach v=1`) +# Вложения в TEXT-сообщениях (`SHiNE:attach v=1/v=2`) Документ фиксирует текущий формат вложений в текстовых блоках SHiNE. @@ -21,6 +21,12 @@ ``` +Блок с превью: + +```text + +``` + Несколько вложений идут подряд в самом начале текста: ```text @@ -43,6 +49,11 @@ - `sha256` - SHA-256 исходного файла в hex, 64 символа; - `ar` - короткий Arweave Transaction ID, 43 символа, без gateway URL. +Дополнительные поля для `v=2`: + +- `previewAr` - короткий Arweave Transaction ID файла-превью; +- `previewSha256` - SHA-256 файла-превью в hex. + ## Хранение файла Файл хранится вне блокчейна в Arweave. В блокчейне хранится только метаинформация, достаточная для отображения и проверки: @@ -52,6 +63,8 @@ - SHA-256; - Arweave `txId`. +Опционально для видео или больших изображений может храниться отдельный второй файл-превью. В таком случае основной attach-блок остаётся одним, но дополнительно указывает `previewAr/previewSha256`. + ## Создание вложения в UI UI поддерживает три сценария: @@ -60,6 +73,15 @@ UI поддерживает три сценария: - ввести существующий `txId`, скачать файл через Arweave gateway и локально посчитать `size/sha256`; - выбрать файл из журнала файлов, загруженных в текущей браузерной сессии. +Для видеофайлов UI может по желанию пользователя автоматически создать превью из первого кадра. Технически клиент загружает два файла: + +- основной видеофайл; +- отдельное изображение-превью. + +После успешной загрузки в журнале хранится один элемент основного файла, но с полями `previewAr/previewSha256`. В UI такой элемент помечается как файл `С превью`. + +При ручном добавлении существующего `txId` для видео UI также позволяет вручную указать `txId` файла-превью. + Журнал хранится только в `sessionStorage` текущего браузера и не является частью блокчейна. В настройках клиента есть отдельный экран `Загрузка файлов`: пользователь может заранее загрузить файл в Arweave без создания сообщения, увидеть компактную плитку с именем, размером, временем загрузки, `txId` и статусом доступности через gateway, а затем выбрать этот файл из истории при создании сообщения. Если файл загружен заранее, но ещё не был отправлен ни в одном сообщении SHiNE, клиент показывает локальный флаг `Не добавлен в SHiNE`. Флаг снимается после успешной отправки сообщения с этим вложением. @@ -77,8 +99,9 @@ UI поддерживает три сценария: - изображение показывает как ограниченное по размеру превью; - видео показывает как превью с кнопкой воспроизведения и открывает большой HTML5-плеер по нажатию; - обычный файл показывает как карточку с именем, расширением, размером и скачиванием; -6. при перелистывании останавливает воспроизводящееся видео; -7. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. +6. если у видео есть `previewAr/previewSha256`, использует отдельный preview-файл как `poster` и как большую превью-плитку в журнале загрузок; +7. при перелистывании останавливает воспроизводящееся видео; +8. если attach-блок битый, игнорирует только этот блок и продолжает отображать сообщение. Если файл по ссылке Arweave gateway недоступен, клиент показывает диагностический блок: @@ -91,9 +114,11 @@ UI поддерживает три сценария: При редактировании сообщения UI сохраняет существующие attach-блоки и меняет только пользовательский текст. Удаление сообщения по-прежнему выполняется через edit-блок с пустым текстом, как описано в `11_TEXT_Blocks.md`. -## Ограничения v1 +## Совместимость и ограничения - `ar` допускает только короткий Arweave `txId`, полный URL не используется; - MIME type не записывается, поэтому UI определяет image/video/file по расширению имени файла; -- MIME type, width, height, duration и thumbnail не записываются в блокчейн; +- старые блоки `v=1` без превью остаются валидными и читаются без изменений; +- `previewAr/previewSha256` используются только если присутствуют оба поля и оба валидны; +- MIME type, width, height, duration и thumbnail не записываются в блокчейн отдельными полями; - проверка существующего `txId` скачивает файл локально через gateway, поэтому UI ограничивает максимальный размер такой проверки. diff --git a/docs/Blockchain/CHANGELOG.md b/docs/Blockchain/CHANGELOG.md index f76eb71a..e9b5a317 100644 --- a/docs/Blockchain/CHANGELOG.md +++ b/docs/Blockchain/CHANGELOG.md @@ -1,5 +1,11 @@ # История изменений документации блокчейна +## 2026-08-04 12:00:00 +0400 +- Базовый коммит-ориентир: `391b18a`. +- Формат вложений расширен до `SHiNE:attach v=2` для опционального второго preview-файла: добавлены поля `previewAr` и `previewSha256` при сохранении совместимости со старыми `v=1`. +- В UI загрузки для видео добавлен сценарий `Добавить превью`: клиент может извлечь первый кадр, загрузить его отдельным файлом в Arweave или через Turbo и сохранить связь preview с основным вложением. +- В журнале загрузок файлы с preview помечаются явно и показываются расширенной плиткой с картинкой превью. + ## 2026-07-31 17:22:07 +0400 - Базовый коммит-ориентир: `05bd9f5`. - Уточнён UI вложений: превью последнего сообщения в списке каналов скрывает технические attach-блоки, карусель не показывает неактивные стрелки на краях, обычный файл отображается компактной кликабельной полоской без `txId`; формат attach-блока не изменён. diff --git a/docs/Blockchain/README.md b/docs/Blockchain/README.md index bce27443..438eb4ff 100644 --- a/docs/Blockchain/README.md +++ b/docs/Blockchain/README.md @@ -18,7 +18,7 @@ 7. [14_USER_PARAM_Blocks.md](./14_USER_PARAM_Blocks.md) Параметры пользователя (`msg_type=4`). 8. [15_TEXT_Attachments.md](./15_TEXT_Attachments.md) - Вложения в TEXT-сообщениях через `SHiNE:attach v=1`. + Вложения в TEXT-сообщениях через `SHiNE:attach v=1/v=2`, включая опциональные `previewAr/previewSha256` для видео и крупных изображений. 9. [16_TEXT_Channel_Meta.md](./16_TEXT_Channel_Meta.md) Скрытый `TEXT_CHANNEL_META` для профиля канала. 10. [01_Channel_Types_and_CreateChannel.md](./01_Channel_Types_and_CreateChannel.md) diff --git a/shine-UI/js/components/arweave-attachment-manager.js b/shine-UI/js/components/arweave-attachment-manager.js index a2539914..24e8beaf 100644 --- a/shine-UI/js/components/arweave-attachment-manager.js +++ b/shine-UI/js/components/arweave-attachment-manager.js @@ -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 ` +
+ Превью ${escapeHtml(item.name)} +
Есть превью
+
+ `; +} + 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 + ? ' (пока файлы до 100 KB бесплатно)' + : ''; + metaEl.innerHTML = ` +
Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}
+
Turbo balance: ${escapeHtml(turboCredits)}
+
SOL на этом ключе: ${solBalance}
+
Размер файла: ${escapeHtml(fileSize)}
+ ${previewSize ? `
Размер превью: ${escapeHtml(previewSize)}
` : ''} +
SHA-256: ${escapeHtml(selectedSha256 || '—')}
+ ${selectedPreviewEnabled && selectedPreviewSha256 ? `
SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}
` : ''} +
Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}
+
До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}
+ `; + }; + + 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 ? '' : `
Имя: ${escapeHtml(selectedFile?.name || 'file')}
`} +
Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}
+
SHA-256: ${escapeHtml(selectedSha256 || '—')}
+ ${selectedPreviewEnabled && selectedPreviewFile + ? `
Превью: ${escapeHtml(selectedPreviewFile.name)} · ${escapeHtml(formatBytes(selectedPreviewFile.size))}
` + : ''} + ${selectedPreviewEnabled && selectedPreviewSha256 + ? `
SHA-256 превью: ${escapeHtml(selectedPreviewSha256)}
` + : ''} +
Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+
Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR
+ `; + }; + + 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 = `