SHiNE-server/shine-UI/js/pages/developer-settings-view.js
2026-06-22 21:57:09 +04:00

370 lines
15 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, state } from '../state.js';
import {
isClientErrorReportingEnabled,
setClientErrorReportingEnabled,
} from '../services/client-error-reporter.js';
import {
canInstallPwa,
isStandalonePwaMode,
onPwaInstallAvailabilityChange,
promptPwaInstall,
} from '../services/pwa-install-service.js';
import { initPwaPush } from '../services/pwa-push-service.js';
import { getArweaveWalletFromStoredClientKey } from '../services/arweave-wallet-service.js';
import {
prepareAvatarImageFile,
uploadArweaveFile,
validateAvatarSourceFile,
} from '../services/arweave-file-service.js';
export const pageMeta = { id: 'developer-settings-view', title: 'Настройки разработчика' };
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 getArweaveWalletFromStoredClientKey({
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;
}
});
}
async function forceUiUpdateNow() {
try {
window.history.replaceState({}, '', '/settings-view');
window.dispatchEvent(new PopStateEvent('popstate'));
} catch {}
if (!('serviceWorker' in navigator)) {
window.location.reload();
return;
}
try {
const registrations = await navigator.serviceWorker.getRegistrations();
await Promise.all(registrations.map(async (registration) => {
try { await registration.update(); } catch {}
if (registration.waiting) {
try { registration.waiting.postMessage({ type: 'SKIP_WAITING' }); } catch {}
}
}));
} catch {}
window.setTimeout(() => window.location.reload(), 450);
}
function showClientUpdateHelp() {
window.alert(
'Если UI не обновился:\n\n'
+ '1) Закройте вкладки с SHiNE.\n'
+ '2) Откройте chrome://settings/siteData и удалите данные для shineup.me.\n'
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
+ '4) Откройте https://shineup.me заново и выполните вход.\n'
+ '5) Если всё ещё старая версия — откройте в режиме инкогнито и проверьте версию в Настройки -> Версии.'
);
}
function openUiErrorReportingModal() {
const root = document.getElementById('modal-root');
if (!root) return;
const enabled = isClientErrorReportingEnabled();
root.innerHTML = `
<div class="modal" id="settings-ui-error-reporting-modal">
<div class="modal-card stack">
<h3 class="modal-title">Отправка UI-ошибок на сервер</h3>
<p class="meta-muted">Для разработчиков: при включении ошибки интерфейса будут автоматически отправляться на сервер для диагностики.</p>
<label class="row wrap-row" for="ui-error-reporting-toggle">
<input id="ui-error-reporting-toggle" type="checkbox" ${enabled ? 'checked' : ''} />
<span>Отправлять ошибки на сервер</span>
</label>
<div class="form-actions-grid">
<button class="secondary-btn" id="ui-error-reporting-cancel" type="button">Отмена</button>
<button class="primary-btn" id="ui-error-reporting-save" type="button">Сохранить</button>
</div>
</div>
</div>
`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#ui-error-reporting-cancel')?.addEventListener('click', close);
root.querySelector('#ui-error-reporting-save')?.addEventListener('click', () => {
const checked = root.querySelector('#ui-error-reporting-toggle')?.checked === true;
setClientErrorReportingEnabled(checked);
close();
});
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
title: 'Настройки разработчика',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
card.innerHTML = `
<button class="text-btn" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
<button class="text-btn" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
<button class="text-btn" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
<button class="text-btn" type="button" id="settings-solana-users-init">Solana: init регистрации</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-pwa-install">Как установить PWA</button>
<button class="text-btn" type="button" id="settings-upload-avatar">Загрузить аватар</button>
`;
const appLogBtn = card.querySelector('#settings-app-log');
const diagnosticsBtn = card.querySelector('#settings-pwa-diagnostics');
const pwaInstallBtn = card.querySelector('#settings-pwa-install');
const uploadAvatarBtn = card.querySelector('#settings-upload-avatar');
const forceUpdateBtn = card.querySelector('#settings-force-ui-update');
const forceUpdateHelpBtn = card.querySelector('#settings-force-update-help');
const uiErrorReportingBtn = card.querySelector('#settings-ui-error-reporting');
const solanaUsersInitBtn = card.querySelector('#settings-solana-users-init');
appLogBtn?.addEventListener('click', () => navigate('app-log-view'));
solanaUsersInitBtn?.addEventListener('click', () => navigate('solana-users-init-view'));
diagnosticsBtn?.addEventListener('click', () => navigate('pwa-diagnostics-view'));
uploadAvatarBtn?.addEventListener('click', () => {
openDeveloperAvatarUploadModal({
walletLogin: state.session.login,
storagePwd: state.session.storagePwdInMemory,
gateway: state.entrySettings.arweaveServer,
});
});
forceUpdateHelpBtn?.addEventListener('click', showClientUpdateHelp);
uiErrorReportingBtn?.addEventListener('click', openUiErrorReportingModal);
forceUpdateBtn?.addEventListener('click', async () => {
forceUpdateBtn.disabled = true;
try {
addAppLogEntry({
level: 'info',
source: 'ui-update',
message: 'Пользователь запросил принудительное обновление UI',
});
await forceUiUpdateNow();
} finally {
forceUpdateBtn.disabled = false;
}
});
const syncPwaButtonLabel = () => {
if (isStandalonePwaMode()) {
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
return;
}
if (canInstallPwa()) {
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
return;
}
pwaInstallBtn.textContent = 'Как установить PWA';
};
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
syncPwaButtonLabel();
});
syncPwaButtonLabel();
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();
}
});
screen.cleanup = () => {
unsubscribeInstallAvailability();
};
screen.append(card);
return screen;
}