Files
SHiNE-server/shine-UI/js/pages/device-view.js
T
qandClaude Opus 5.5 f8900e531a UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов,
  цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли.
- Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие —
  редактор цветов с экспортом/импортом.
- Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале»,
  создание канала с адресом из названия; лента открывается на свежих постах.
- Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке.
- Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка;
  настройки, кошелёк, сеансы — меню-списки.
- Нижняя панель: иконки без подписей, бейджи на иконках.
- confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу.
- docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:05:26 +03:00

181 lines
8.0 KiB
JavaScript

import { showToast } from '../services/channels-ux.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
refreshSessions,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
export const pageMeta = { id: 'device-view', title: 'Устройства' };
function formatSessionType(sessionType) {
if (Number(sessionType) === 100) return 'Домашний сервер';
if (Number(sessionType) === 50) return 'Кошелёк';
if (Number(sessionType) === 1) return 'Приложение';
return `Тип ${Number(sessionType) || 0}`;
}
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
function describeClient(userAgent) {
const ua = String(userAgent || '');
if (!ua) return 'Неизвестное устройство';
const browser = /Edg\//.test(ua) ? 'Edge'
: /OPR\/|Opera/.test(ua) ? 'Opera'
: /Firefox\//.test(ua) ? 'Firefox'
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
: /Chrome\//.test(ua) ? 'Chrome'
: /Safari\//.test(ua) ? 'Safari'
: '';
const os = /iPhone/.test(ua) ? 'iPhone'
: /iPad/.test(ua) ? 'iPad'
: /Android/.test(ua) ? 'Android'
: /Windows/.test(ua) ? 'Windows'
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
: /Linux|X11/.test(ua) ? 'Linux'
: '';
const parts = [browser, os].filter(Boolean);
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
}
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
function formatSessionTime(ms) {
const date = new Date(ms);
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
if (days === 0) return `сегодня, ${time}`;
if (days === 1) return `вчера, ${time}`;
const sameYear = date.getFullYear() === new Date().getFullYear();
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
return `${day}, ${time}`;
}
function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'в сети' : 'не в сети';
}
function sortSessionsByOnline(sessions = []) {
return [...sessions].sort((a, b) => {
const aOnline = a?.onlineOnThisServer ? 1 : 0;
const bOnline = b?.onlineOnThisServer ? 1 : 0;
if (aOnline !== bOnline) return bOnline - aOnline;
return Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0);
});
}
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
chrome?.setTopbar(createTopBar({
title: 'Устройства',
back: { label: '←', onClick: () => navigate('settings-view') },
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
}));
const actions = document.createElement('div');
actions.className = 'nav-list';
actions.innerHTML = `
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
`;
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
actions.querySelector('#connect-device-btn').addEventListener('click', () => navigate('connect-device-view'));
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
const sessionsBlock = document.createElement('div');
sessionsBlock.className = 'stack device-sessions';
const buildList = () => {
sessionsBlock.innerHTML = '';
const sessions = sortSessionsByOnline(state.sessions || []);
const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0];
const others = sortSessionsByOnline(sessions.filter((s) => s.sessionId !== current?.sessionId));
const createSessionItem = (session, isCurrent) => {
const item = document.createElement('button');
item.className = 'ui-button session-item';
item.type = 'button';
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
item.innerHTML = `
<span class="session-item__main">
<strong class="session-item__title"></strong>
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
</span>
<span class="session-item__side">
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
</span>
`;
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
item.title = String(session.clientInfoFromClient || '');
const geoEl = item.querySelector('.session-item__geo');
if (geoEl) geoEl.textContent = session.geo;
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
return item;
};
if (!current) {
const empty = document.createElement('p');
empty.className = 'meta-muted';
empty.textContent = 'Активные сессии не найдены.';
sessionsBlock.append(empty);
return;
}
const groupTitle = (text) => {
const title = document.createElement('p');
title.className = 'nav-list__title';
title.textContent = text;
return title;
};
const currentList = document.createElement('div');
currentList.className = 'nav-list';
currentList.append(createSessionItem(current, true));
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
if (others.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty-note';
empty.textContent = 'Других сеансов нет.';
sessionsBlock.append(empty);
} else {
const othersList = document.createElement('div');
othersList.className = 'nav-list';
others.forEach((session) => othersList.append(createSessionItem(session, false)));
sessionsBlock.append(othersList);
}
};
async function reloadSessions() {
try {
await refreshSessions();
buildList();
setAuthInfo('Список сеансов обновлён.');
showToast('Список сеансов обновлён');
} catch (error) {
if (isSessionInvalidError(error)) {
await terminateCurrentSession({
infoMessage: 'Сессия на этом устройстве уже завершена. Выполните вход заново.',
});
return;
}
setAuthError(error.message);
showToast(error.message, { kind: 'error' });
}
}
buildList();
screen.append(actions, sessionsBlock);
return screen;
}