Настройки: блок Для разработчиков и ручная загрузка аватара в Arweave

This commit is contained in:
AidarKC 2026-04-26 02:46:18 +03:00
parent df7f38bd0a
commit dafdae5276
4 changed files with 238 additions and 10 deletions

View File

@ -1,2 +1,2 @@
client.version=1.2.7
server.version=1.2.7
client.version=1.2.8
server.version=1.2.8

View File

@ -6,8 +6,8 @@
<link rel="manifest" href="./manifest.webmanifest" />
<title>Shine UI Demo</title>
<script>
window.__SHINE_BUILD_HASH__ = '20260426105500';
window.__SHINE_CLIENT_VERSION__ = '1.2.7';
window.__SHINE_BUILD_HASH__ = '20260426113000';
window.__SHINE_CLIENT_VERSION__ = '1.2.8';
</script>
<script>
(function attachStylesWithBuildHash() {

View File

@ -7,6 +7,12 @@ import {
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredDeviceKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@ -32,6 +38,168 @@ function formatVersionForUi(rawValue) {
return value;
}
function clearArweaveJwk(walletCtx) {
if (!walletCtx?.jwk || typeof walletCtx.jwk !== 'object') return;
Object.keys(walletCtx.jwk).forEach((key) => {
walletCtx.jwk[key] = '';
});
walletCtx.jwk = null;
}
function formatBytes(bytes) {
const value = Number(bytes || 0);
if (!Number.isFinite(value) || value <= 0) return '0 B';
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
function openDeveloperAvatarUploadModal({ walletLogin, storagePwd, gateway } = {}) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="settings-dev-avatar-modal">
<div class="modal-card stack settings-dev-avatar-modal-card">
<h3 class="modal-title">Загрузить аватар (Arweave)</h3>
<label class="field-label" for="settings-dev-avatar-login">Логин пользователя</label>
<input class="input" id="settings-dev-avatar-login" type="text" maxlength="60" placeholder="Например: aidar" value="" />
<label class="field-label" for="settings-dev-avatar-file">Файл изображения</label>
<input class="input" id="settings-dev-avatar-file" type="file" accept="image/jpeg,image/png,image/webp" />
<p class="meta-muted">Поддерживаются JPEG, PNG, WebP. Перед загрузкой изображение будет оптимизировано.</p>
<div class="settings-dev-avatar-meta" id="settings-dev-avatar-meta"></div>
<p class="inline-error settings-dev-avatar-error" id="settings-dev-avatar-error"></p>
<div class="form-actions-grid">
<button class="secondary-btn" id="settings-dev-avatar-cancel" type="button">Закрыть</button>
<button class="primary-btn" id="settings-dev-avatar-upload" type="button">Загрузить</button>
</div>
<div class="card stack settings-dev-avatar-result" id="settings-dev-avatar-result" hidden>
<p class="meta-muted">Transaction ID:</p>
<p class="key-value" id="settings-dev-avatar-txid"></p>
<button class="ghost-btn" id="settings-dev-avatar-copy" type="button">Скопировать TX ID</button>
</div>
</div>
</div>
`;
const modal = root.querySelector('#settings-dev-avatar-modal');
const loginInput = root.querySelector('#settings-dev-avatar-login');
const fileInput = root.querySelector('#settings-dev-avatar-file');
const metaEl = root.querySelector('#settings-dev-avatar-meta');
const errorEl = root.querySelector('#settings-dev-avatar-error');
const cancelBtn = root.querySelector('#settings-dev-avatar-cancel');
const uploadBtn = root.querySelector('#settings-dev-avatar-upload');
const resultCard = root.querySelector('#settings-dev-avatar-result');
const txidEl = root.querySelector('#settings-dev-avatar-txid');
const copyBtn = root.querySelector('#settings-dev-avatar-copy');
if (loginInput instanceof HTMLInputElement) {
loginInput.value = String(walletLogin || '').trim();
}
let isClosed = false;
const closeModal = () => {
if (isClosed) return;
isClosed = true;
root.innerHTML = '';
};
const setError = (text) => {
if (errorEl) errorEl.textContent = String(text || '');
};
const setMeta = (text) => {
if (metaEl) metaEl.textContent = String(text || '');
};
const setTxId = (txId) => {
if (!(resultCard instanceof HTMLElement) || !(txidEl instanceof HTMLElement)) return;
const value = String(txId || '').trim();
txidEl.textContent = value;
resultCard.hidden = !value;
};
modal?.addEventListener('click', (event) => {
if (event.target === modal) closeModal();
});
cancelBtn?.addEventListener('click', closeModal);
copyBtn?.addEventListener('click', async () => {
const txId = String(txidEl?.textContent || '').trim();
if (!txId) return;
try {
await navigator.clipboard.writeText(txId);
setError('');
} catch {
setError('Не удалось скопировать TX ID.');
}
});
uploadBtn?.addEventListener('click', async () => {
setError('');
setTxId('');
const targetLogin = String(loginInput?.value || '').trim();
const file = fileInput?.files?.[0] || null;
if (!targetLogin) {
setError('Введите логин пользователя.');
return;
}
if (!file) {
setError('Выберите файл изображения.');
return;
}
if (!String(walletLogin || '').trim() || !String(storagePwd || '').trim()) {
setError('Нет активной сессии. Войдите заново и повторите.');
return;
}
uploadBtn.disabled = true;
let walletCtx = null;
try {
validateAvatarSourceFile(file);
setMeta('Подготовка изображения...');
const optimized = await prepareAvatarImageFile(file);
setMeta(
`Файл подготовлен: ${formatBytes(optimized.originalSizeBytes)}${formatBytes(optimized.optimizedSizeBytes)} ` +
`(${optimized.width}x${optimized.height}, ${optimized.contentType})`,
);
walletCtx = await getArweaveWalletFromStoredDeviceKey({
login: walletLogin,
storagePwd,
onStatus: (message) => setMeta(message),
});
setMeta('Загрузка в Arweave...');
const uploaded = await uploadArweaveFile({
gateway,
jwk: walletCtx?.jwk,
file: optimized.file,
tags: [
{ name: 'SHiNE-Profile-Login', value: targetLogin },
],
});
const txId = String(uploaded?.id || '').trim();
if (!txId) throw new Error('Пустой Transaction ID');
setMeta('Загрузка завершена.');
setTxId(txId);
} catch (error) {
const message = String(error?.message || '');
if (
message === 'Выберите файл изображения.'
|| message === 'Поддерживаются только JPEG, PNG или WebP.'
|| message === 'Файл слишком большой. Максимум 10 MB.'
|| message === 'Не удалось подготовить изображение.'
) {
setError(message);
} else {
setError('Не удалось загрузить аватар в Arweave.');
}
} finally {
clearArweaveJwk(walletCtx);
uploadBtn.disabled = false;
}
});
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
@ -50,20 +218,50 @@ export function render({ navigate }) {
<button class="text-btn" type="button" id="settings-device">Устройства</button>
<button class="text-btn" type="button" id="settings-servers">Настройки серверов</button>
<button class="text-btn" type="button" id="settings-language">Язык / Language</button>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</button>
<button class="text-btn" type="button" id="settings-pwa-install">Зарегистрировать PWA</button>
`;
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
card.querySelector('#settings-servers').addEventListener('click', () => navigate('server-settings-view'));
card.querySelector('#settings-language').addEventListener('click', () => navigate('language-view'));
card.querySelector('#settings-app-log').addEventListener('click', () => navigate('app-log-view'));
card.querySelector('#settings-pwa-diagnostics').addEventListener('click', () => navigate('pwa-diagnostics-view'));
const signOutBtn = card.querySelector('#settings-signout');
const pwaInstallBtn = card.querySelector('#settings-pwa-install');
const developerCard = document.createElement('div');
developerCard.className = 'card stack settings-developer-card';
developerCard.innerHTML = `
<button class="text-btn" type="button" id="settings-dev-toggle">Для разработчиков</button>
<div class="stack settings-developer-panel" id="settings-dev-panel" hidden>
<button class="text-btn" type="button" id="settings-app-log">Лог приложения</button>
<button class="text-btn" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
<button class="text-btn" type="button" id="settings-pwa-install">Как установить PWA</button>
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
</div>
`;
const developerToggleBtn = developerCard.querySelector('#settings-dev-toggle');
const developerPanel = developerCard.querySelector('#settings-dev-panel');
const appLogBtn = developerCard.querySelector('#settings-app-log');
const diagnosticsBtn = developerCard.querySelector('#settings-pwa-diagnostics');
const pwaInstallBtn = developerCard.querySelector('#settings-pwa-install');
const uploadAvatarBtn = developerCard.querySelector('#settings-upload-avatar');
developerToggleBtn?.addEventListener('click', () => {
const isHidden = developerPanel?.hidden !== false;
if (developerPanel) developerPanel.hidden = !isHidden;
if (developerToggleBtn) {
developerToggleBtn.textContent = isHidden ? 'Для разработчиков ▲' : 'Для разработчиков ▼';
}
});
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
walletLogin: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
});
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
@ -191,5 +389,6 @@ export function render({ navigate }) {
unsubscribeInstallAvailability();
};
screen.append(versionCard);
screen.append(developerCard);
return screen;
}

View File

@ -1092,6 +1092,35 @@
border: 1px solid rgba(134, 157, 205, 0.2);
}
.settings-developer-card {
margin-top: 4px;
}
.settings-developer-panel {
margin-top: 2px;
padding-top: 8px;
border-top: 1px solid rgba(132, 157, 206, 0.22);
}
.settings-dev-avatar-modal-card {
max-width: 520px;
}
.settings-dev-avatar-meta {
min-height: 18px;
font-size: 13px;
color: #d9e7ff;
}
.settings-dev-avatar-error {
min-height: 18px;
color: #f0a9b3;
}
.settings-dev-avatar-result {
gap: 8px;
}
.contact-search-actions {
display: grid;
grid-template-columns: 1fr;