SHiNE-server/shine-UI/js/pages/settings-view.js

406 lines
16 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 { renderHeader } from '../components/header.js';
import { addAppLogEntry, authService, closeCurrentSessionAndSignOut, state } from '../state.js';
import {
canInstallPwa,
isStandalonePwaMode,
onPwaInstallAvailabilityChange,
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: 'Настройки' };
function formatBuildStamp(rawValue) {
const value = String(rawValue || '').trim();
if (!/^\d{14}$/.test(value)) return value;
const yyyy = value.slice(0, 4);
const mm = value.slice(4, 6);
const dd = value.slice(6, 8);
const hh = value.slice(8, 10);
const min = value.slice(10, 12);
const ss = value.slice(12, 14);
return `${yyyy}-${mm}-${dd}/${hh}:${min}__${ss}`;
}
function formatVersionForUi(rawValue) {
const value = String(rawValue || '').trim();
if (!value) return 'n/a';
const formatted = formatBuildStamp(value);
if (formatted && formatted !== value) {
return `${formatted} (${value})`;
}
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';
let isDisposed = false;
screen.append(
renderHeader({
title: 'Настройки',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack';
card.innerHTML = `
<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-force-update-help">Клиент не обновляется?</button>
<button class="text-btn" type="button" id="settings-signout">Завершить текущий сеанс</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-force-update-help').addEventListener('click', () => {
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '2) Откройте chrome://settings/siteData и удалите данные для shineup.me.\n'
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '4) Откройте https://shineup.me заново и выполните вход.\n'
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
});
const signOutBtn = card.querySelector('#settings-signout');
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()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
return;
}
if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
return;
}
pwaInstallBtn.textContent = 'Как установить PWA';
};
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
syncPwaButtonLabel();
});
syncPwaButtonLabel();
signOutBtn.addEventListener('click', async () => {
const confirmed = window.confirm(
'Завершить текущую сессию на сервере, отключиться, очистить локальные данные и перейти на стартовый экран?'
);
if (!confirmed) return;
signOutBtn.disabled = true;
try {
addAppLogEntry({
level: 'info',
source: 'session',
message: `Запрошено завершение текущей сессии: ${state.session.sessionId || 'unknown'}`,
});
await closeCurrentSessionAndSignOut({
infoMessage: 'Сеанс завершён. Выполните вход заново.',
});
} finally {
signOutBtn.disabled = false;
}
});
pwaInstallBtn.addEventListener('click', async () => {
pwaInstallBtn.disabled = true;
try {
await initPwaPush({
authService,
onLog: (entry) => addAppLogEntry(entry),
});
if (canInstallPwa()) {
const result = await promptPwaInstall();
const accepted = result.outcome === 'accepted';
addAppLogEntry({
level: 'info',
source: 'pwa-install',
message: accepted ? 'Пользователь принял установку PWA' : 'Пользователь отклонил установку PWA',
details: { outcome: result.outcome || 'unknown' },
});
if (accepted) {
window.alert('Установка PWA подтверждена. Проверьте приложение на главном экране устройства.');
}
} else if (!isStandalonePwaMode()) {
window.alert('Для установки откройте меню браузера и выберите "Установить приложение" или "Добавить на главный экран".');
} else {
window.alert('PWA уже установлено. WebPush перерегистрирован.');
}
} catch (error) {
addAppLogEntry({
level: 'warn',
source: 'pwa-install',
message: 'Не удалось зарегистрировать PWA/WebPush',
details: { error: error?.message || 'unknown' },
});
window.alert(`Ошибка регистрации PWA: ${error?.message || 'unknown'}`);
} finally {
pwaInstallBtn.disabled = false;
syncPwaButtonLabel();
}
});
const versionCard = document.createElement('div');
versionCard.className = 'card stack';
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Версии';
const clientVersion = document.createElement('p');
clientVersion.className = 'meta-muted';
clientVersion.textContent = `Клиент: ${formatVersionForUi(window.__SHINE_CLIENT_VERSION__)}`;
const uiBuild = document.createElement('p');
uiBuild.className = 'meta-muted';
uiBuild.textContent = `Сборка UI: ${formatVersionForUi(window.__SHINE_BUILD_HASH__)}`;
const serverVersion = document.createElement('p');
serverVersion.className = 'meta-muted';
serverVersion.textContent = 'Сервер: загружается...';
versionCard.append(title, clientVersion, uiBuild, serverVersion);
void (async () => {
try {
let value = '';
try {
const pingResp = await authService.ws.request('Ping', { ts: Date.now() }, 7000);
value = String(pingResp?.payload?.serverVersion || pingResp?.serverVersion || '').trim();
} catch {
// fallback below
}
if (!value) {
const infoResp = await authService.ws.request('GetServerInfo', {});
value = String(infoResp?.payload?.version || '').trim();
}
if (!isDisposed) serverVersion.textContent = `Сервер: ${formatVersionForUi(value)}`;
} catch {
if (!isDisposed) {
serverVersion.textContent = 'Сервер: недоступно';
}
}
})();
screen.append(card);
screen.cleanup = () => {
isDisposed = true;
unsubscribeInstallAvailability();
};
screen.append(versionCard);
screen.append(developerCard);
return screen;
}