Каналы: добавить профиль через TEXT_CHANNEL_META

This commit is contained in:
AidarKC
2026-08-01 01:57:41 +04:00
parent 24afcba20a
commit 110ff5e12e
31 changed files with 1312 additions and 191 deletions
@@ -16,8 +16,10 @@ 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,
};
@@ -133,6 +135,36 @@ function shortAddress(value) {
return `${raw.slice(0, 8)}...${raw.slice(-6)}`;
}
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() });
}
export function openArweaveAttachmentManager({
login,
storagePwd,
@@ -140,6 +172,8 @@ export function openArweaveAttachmentManager({
onSelect,
selectedTxIds = [],
historyOnly = false,
mode = 'attachment',
historyPurpose = '',
} = {}) {
const cleanLogin = String(login || '').trim();
const cleanStoragePwd = String(storagePwd || '').trim();
@@ -158,6 +192,8 @@ export function openArweaveAttachmentManager({
let priceInfo = null;
let balanceInfo = null;
let autoOpenedFileDialog = false;
const isAvatarMode = String(mode || '') === 'avatar';
const purposeFilter = isAvatarMode || String(historyPurpose || '').trim() === 'avatar' ? 'avatar' : '';
const selectedTxIdSet = new Set((Array.isArray(selectedTxIds) ? selectedTxIds : []).map((item) => String(item || '').trim()).filter(Boolean));
function selectedWallet() {
@@ -207,18 +243,18 @@ export function openArweaveAttachmentManager({
root.innerHTML = `
<div class="modal" data-ar-attach-modal="true">
<div class="modal-card stack ar-attachment-manager-card">
<h3 class="modal-title">${historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'}</h3>
<h3 class="modal-title">${isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')}</h3>
<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>
${historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>'}
${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" />
<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
<div class="ar-attachment-meta" data-meta="true"></div>
<p class="meta-muted inline-error" data-error="true"></p>
<div class="form-actions-grid">
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
${historyOnly ? '' : '<button class="secondary-btn" type="button" data-action="history">История загрузок</button>'}
${historyOnly || isAvatarMode ? '' : '<button class="secondary-btn" type="button" data-action="existing">Ввести txId</button>'}
${historyOnly ? '' : `<button class="secondary-btn" type="button" data-action="history">${isAvatarMode ? 'История аватаров' : 'История загрузок'}</button>`}
<button class="primary-btn" type="button" data-action="upload" disabled>${historyOnly ? 'Загрузить в журнал' : 'Загрузить'}</button>
</div>
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
@@ -266,13 +302,19 @@ export function openArweaveAttachmentManager({
try {
const wallet = selectedWallet();
if (!wallet?.address) throw new Error('Выберите Arweave-кошелёк.');
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);
priceInfo = await getArweaveUploadPrice({ gateway: cleanGateway, byteLength: selectedFile.size });
balanceInfo = await getArweaveBalance({ gateway: cleanGateway, address: wallet.address });
const hasFunds = BigInt(balanceInfo.winston) >= BigInt(priceInfo.winston);
metaEl.innerHTML = `
<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile.size))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256)}</div>
<div>Цена: ${escapeHtml(Number(priceInfo.ar).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
@@ -305,19 +347,20 @@ export function openArweaveAttachmentManager({
gateway: cleanGateway,
jwk: wallet.jwk,
file: selectedFile,
shineType: 'attachment',
shineType: isAvatarMode ? 'avatar' : 'attachment',
tags: [
{ name: 'SHiNE-Login', value: cleanLogin },
{ name: 'SHiNE-Attachment-Name', value: selectedFile.name || 'file' },
{ name: isAvatarMode ? 'SHiNE-Avatar' : 'SHiNE-Attachment-Name', value: isAvatarMode ? '1' : (selectedFile.name || 'file') },
],
});
finish(resolve, {
name: selectedFile.name || 'file',
name: isAvatarMode ? 'Аватар' : (selectedFile.name || 'file'),
size: selectedFile.size,
sha256: selectedSha256,
ar: uploaded.id,
uploadedAtMs: Date.now(),
}, { pendingPlacement: historyOnly });
purpose: isAvatarMode ? 'avatar' : 'attachment',
}, { pendingPlacement: historyOnly && !isAvatarMode });
} catch (error) {
uploadBtn.disabled = false;
setText(errorEl, error?.message || 'Не удалось загрузить файл в Arweave.');
@@ -394,7 +437,10 @@ export function openArweaveAttachmentManager({
};
const showHistory = () => {
const rows = readArweaveAttachmentHistory(cleanLogin).slice().reverse();
const rows = readArweaveAttachmentHistory(cleanLogin)
.filter((item) => !purposeFilter || item.purpose === purposeFilter)
.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">
@@ -405,7 +451,7 @@ export function openArweaveAttachmentManager({
const isSelected = selectedTxIdSet.has(item.ar);
return `
<article class="ar-attachment-history-tile${isSelected ? ' is-selected' : ''}" data-history-tile="${index}">
<strong class="ar-attachment-history-name">${escapeHtml(item.name)}</strong>
<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>