UI: добавить журнал загрузок Arweave

This commit is contained in:
AidarKC
2026-07-31 13:13:58 +04:00
parent c530627078
commit 98eba54621
11 changed files with 310 additions and 27 deletions
+2
View File
@@ -59,6 +59,7 @@ import * as settingsView from './pages/settings-view.js';
import * as accessServersView from './pages/access-servers-view.js';
import * as developerSettingsView from './pages/developer-settings-view.js';
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
@@ -120,6 +121,7 @@ const routes = {
'access-servers-view': accessServersView,
'developer-settings-view': developerSettingsView,
'server-settings-view': serverSettingsView,
'arweave-uploads-view': arweaveUploadsView,
'remote-addblock-session-view': remoteAddBlockSessionView,
'device-view': deviceView,
'connect-device-view': connectDeviceView,
@@ -1,4 +1,5 @@
import {
buildArweaveDataUrl,
getArweaveUploadPrice,
loadArweaveFileMetadata,
sha256HexFromArrayBuffer,
@@ -10,8 +11,9 @@ import { escapeHtml, formatBytes, normalizeAttachment } from '../services/attach
const HISTORY_KEY = 'shine-ui-arweave-attachment-history-v1';
const MAX_HISTORY_ITEMS = 100;
const RECENT_UPLOAD_MS = 20 * 60 * 1000;
function readHistory(login) {
export function readArweaveAttachmentHistory(login) {
try {
const raw = sessionStorage.getItem(HISTORY_KEY);
const parsed = raw ? JSON.parse(raw) : {};
@@ -23,7 +25,7 @@ function readHistory(login) {
}
}
function saveHistory(login, rows) {
export function saveArweaveAttachmentHistory(login, rows) {
try {
const key = String(login || '').trim().toLowerCase();
const raw = sessionStorage.getItem(HISTORY_KEY);
@@ -35,20 +37,40 @@ function saveHistory(login, rows) {
}
}
function addToHistory(login, attachment) {
export function clearArweaveAttachmentHistory(login) {
saveArweaveAttachmentHistory(login, []);
}
export function addArweaveAttachmentToHistory(login, attachment) {
const item = normalizeAttachment(attachment);
const rows = readHistory(login).filter((row) => row.ar !== item.ar);
const rows = readArweaveAttachmentHistory(login).filter((row) => row.ar !== item.ar);
rows.unshift(item);
saveHistory(login, rows);
saveArweaveAttachmentHistory(login, rows);
return item;
}
function formatDateTime(ms) {
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 || '');
}
@@ -64,6 +86,7 @@ export function openArweaveAttachmentManager({
storagePwd,
gateway,
onSelect,
selectedTxIds = [],
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -82,6 +105,7 @@ export function openArweaveAttachmentManager({
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
function selectedWallet() {
return wallets.find((item) => String(item.id) === String(selectedWalletId)) || wallets[0] || null;
@@ -95,7 +119,7 @@ export function openArweaveAttachmentManager({
}
function finish(resolve, attachment) {
const item = addToHistory(cleanLogin, attachment);
const item = addArweaveAttachmentToHistory(cleanLogin, attachment);
if (typeof onSelect === 'function') onSelect(item);
close(resolve, item);
}
@@ -131,13 +155,14 @@ export function openArweaveAttachmentManager({
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
<select class="input" id="ar-attach-wallet"></select>
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>
<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>
<input class="input" id="ar-attach-file" type="file" />
<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="existing">Ввести txId</button>
<button class="secondary-btn" type="button" data-action="history">История</button>
<button class="secondary-btn" type="button" data-action="history">История загрузок</button>
<button class="primary-btn" type="button" data-action="upload" disabled>Загрузить</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
@@ -313,29 +338,27 @@ export function openArweaveAttachmentManager({
};
const showHistory = () => {
const rows = readHistory(cleanLogin);
const rows = readArweaveAttachmentHistory(cleanLogin).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-table-wrap">
<table class="ar-attachment-history-table">
<thead>
<tr><th>Файл</th><th>txId</th><th>Размер</th><th>Дата</th><th></th></tr>
</thead>
<tbody>
${rows.length ? rows.map((item, index) => `
<tr>
<td>${escapeHtml(item.name)}</td>
<td class="mono-cell">${escapeHtml(item.ar)}</td>
<td>${escapeHtml(formatBytes(item.size))}</td>
<td>${escapeHtml(formatDateTime(item.uploadedAtMs))}</td>
<td><button class="ghost-btn" type="button" data-pick="${index}">Выбрать</button></td>
</tr>
`).join('') : '<tr><td colspan="5">В этой сессии ещё нет загруженных файлов.</td></tr>'}
</tbody>
</table>
<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${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
<div class="ar-attachment-history-tile-head">
<strong>${escapeHtml(item.name)}</strong>
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
</div>
<div class="meta-muted">${escapeHtml(formatBytes(item.size))} · ${escapeHtml(formatArweaveHistoryDateTime(item.uploadedAtMs))}</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>
@@ -344,6 +367,10 @@ export function openArweaveAttachmentManager({
</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', () => {
@@ -352,6 +379,20 @@ export function openArweaveAttachmentManager({
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 = () => {
+133
View File
@@ -0,0 +1,133 @@
import { renderHeader } from '../components/header.js';
import {
clearArweaveAttachmentHistory,
formatArweaveHistoryDateTime,
getArweaveAttachmentAvailability,
openArweaveAttachmentManager,
readArweaveAttachmentHistory,
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузки в Arweave' };
function setStatusBadge(node, status, label) {
if (!node) return;
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
node.textContent = label || 'Проверяем...';
}
function renderTile(item, index) {
const tile = document.createElement('article');
tile.className = 'ar-attachment-history-tile ar-attachment-history-tile--page';
const head = document.createElement('div');
head.className = 'ar-attachment-history-tile-head';
const name = document.createElement('strong');
name.textContent = item.name;
const status = document.createElement('span');
status.className = 'ar-attachment-status ar-attachment-status--pending';
status.dataset.statusIndex = String(index);
status.textContent = 'Проверяем...';
const meta = document.createElement('div');
meta.className = 'meta-muted';
meta.textContent = `${formatBytes(item.size)} · ${formatArweaveHistoryDateTime(item.uploadedAtMs)}`;
const txId = document.createElement('div');
txId.className = 'mono-cell ar-attachment-history-txid';
txId.textContent = item.ar;
head.append(name, status);
tile.append(head, meta, txId);
return tile;
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack arweave-uploads-screen';
screen.append(
renderHeader({
title: 'Загрузки в Arweave',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const intro = document.createElement('div');
intro.className = 'card stack';
intro.innerHTML = `
<p class="field-label">Журнал файлов этой сессии</p>
<p class="meta-muted">Здесь можно заранее загрузить файл в Arweave. Он попадёт только в журнал, а в сообщение его потом можно добавить из истории загрузок.</p>
`;
const controls = document.createElement('div');
controls.className = 'card arweave-uploads-toolbar';
controls.innerHTML = `
<button class="primary-btn" type="button" data-action="upload">Добавить файл</button>
<button class="secondary-btn" type="button" data-action="clear">Очистить историю</button>
`;
const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error';
const list = document.createElement('div');
list.className = 'ar-attachment-history-tiles arweave-uploads-list';
function renderHistory() {
const rows = readArweaveAttachmentHistory(state.session.login).slice().reverse();
list.innerHTML = '';
statusLine.textContent = rows.length ? `Файлов в журнале: ${rows.length}` : 'Журнал пока пуст.';
if (!rows.length) {
const empty = document.createElement('div');
empty.className = 'card meta-muted';
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
list.append(empty);
return;
}
rows.forEach((item, index) => {
list.append(renderTile(item, index));
const statusEl = list.querySelector(`[data-status-index="${index}"]`);
void getArweaveAttachmentAvailability({
gateway: state.entrySettings.arweaveServer,
attachment: item,
})
.then((result) => setStatusBadge(statusEl, result.status, result.label))
.catch(() => setStatusBadge(statusEl, 'unavailable', 'Недоступен'));
});
window.setTimeout(() => {
list.scrollTop = list.scrollHeight;
}, 0);
}
controls.querySelector('[data-action="upload"]')?.addEventListener('click', async () => {
statusLine.textContent = '';
try {
await openArweaveAttachmentManager({
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
renderHistory();
} catch (error) {
statusLine.textContent = toUserMessage(error, 'Не удалось загрузить файл.');
}
});
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
});
screen.append(intro, controls, statusLine, list);
renderHistory();
return screen;
}
+1
View File
@@ -426,6 +426,7 @@ function openReplyModal({ onSubmit, navigate }) {
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
+2
View File
@@ -401,6 +401,7 @@ function openReplyModal({ onSubmit, navigate }) {
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
@@ -559,6 +560,7 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
login: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
attachments.push(item);
+7
View File
@@ -54,6 +54,12 @@ export function render({ navigate }) {
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-arweave-uploads">
<span style="display:block; text-align:left;">
<strong>Загрузки в Arweave</strong>
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
</span>
</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
`;
@@ -62,6 +68,7 @@ export function render({ navigate }) {
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
card.querySelector('#settings-access-servers').addEventListener('click', () => navigate('access-servers-view'));
card.querySelector('#settings-blockchain-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-arweave-uploads').addEventListener('click', () => navigate('arweave-uploads-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
const signOutBtn = card.querySelector('#settings-signout');
+3
View File
@@ -31,6 +31,7 @@ const PRETTY_PATHS = new Map([
['settings-view', 'settings'],
['access-servers-view', 'settings/access-servers'],
['server-settings-view', 'settings/servers'],
['arweave-uploads-view', 'settings/arweave-uploads'],
['developer-settings-view', 'settings/developer'],
['trusted-device-login-settings-view', 'settings/device-login'],
['language-view', 'settings/language'],
@@ -261,6 +262,7 @@ export function getRoute() {
const sub = decodePart(segments[1] || '').toLowerCase();
if (sub === 'access-servers') return { pageId: 'access-servers-view', params: {} };
if (sub === 'servers') return { pageId: 'server-settings-view', params: {} };
if (sub === 'arweave-uploads') return { pageId: 'arweave-uploads-view', params: {} };
if (sub === 'developer') return { pageId: 'developer-settings-view', params: {} };
if (sub === 'device-login') return { pageId: 'trusted-device-login-settings-view', params: {} };
if (sub === 'language') return { pageId: 'language-view', params: {} };
@@ -373,6 +375,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'access-servers-view' ||
pageId === 'developer-settings-view' ||
pageId === 'server-settings-view' ||
pageId === 'arweave-uploads-view' ||
pageId === 'remote-addblock-session-view' ||
pageId === 'device-view' ||
pageId === 'connect-device-view' ||