SHA256
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>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
@@ -11,24 +12,49 @@ import {
|
||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||
|
||||
function formatSessionType(sessionType) {
|
||||
if (Number(sessionType) === 100) return 'Homeserver';
|
||||
if (Number(sessionType) === 50) return 'Wallet';
|
||||
if (Number(sessionType) === 1) return 'Client';
|
||||
return `Type ${Number(sessionType) || 0}`;
|
||||
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) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
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 ? 'Online now' : 'Offline';
|
||||
return onlineOnThisServer ? 'в сети' : 'не в сети';
|
||||
}
|
||||
|
||||
function sortSessionsByOnline(sessions = []) {
|
||||
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
|
||||
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 = 'card stack';
|
||||
actions.className = 'nav-list';
|
||||
actions.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<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'));
|
||||
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
const sessionsBlock = document.createElement('div');
|
||||
sessionsBlock.className = 'card stack';
|
||||
sessionsBlock.className = 'stack device-sessions';
|
||||
|
||||
const buildList = () => {
|
||||
sessionsBlock.innerHTML = '';
|
||||
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
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 = `
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
||||
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
|
||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||
</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;
|
||||
};
|
||||
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMenu = document.createElement('div');
|
||||
currentMenu.className = 'stack';
|
||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('Хотите завершить текущую сессию?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
closeServerSession: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
}
|
||||
});
|
||||
currentMenu.append(endCurrentSessionBtn);
|
||||
|
||||
const othersMenu = document.createElement('div');
|
||||
othersMenu.className = 'stack';
|
||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
|
||||
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 = 'meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Других сеансов нет.';
|
||||
othersMenu.append(empty);
|
||||
sessionsBlock.append(empty);
|
||||
} else {
|
||||
others.forEach((session) => {
|
||||
othersMenu.append(createSessionItem(session, false));
|
||||
});
|
||||
const othersList = document.createElement('div');
|
||||
othersList.className = 'nav-list';
|
||||
others.forEach((session) => othersList.append(createSessionItem(session, false)));
|
||||
sessionsBlock.append(othersList);
|
||||
}
|
||||
|
||||
sessionsBlock.append(currentMenu, othersMenu);
|
||||
};
|
||||
|
||||
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||
async function reloadSessions() {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список сессий обновлён.');
|
||||
setAuthInfo('Список сеансов обновлён.');
|
||||
showToast('Список сеансов обновлён');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
showToast(error.message, { kind: 'error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildList();
|
||||
screen.append(actions, sessionsBlock);
|
||||
|
||||
Reference in New Issue
Block a user