Compare commits

...
2 Commits
90 changed files with 15065 additions and 13682 deletions
+1278 -37
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -18,7 +18,7 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
<script>
(function attachStylesWithBuildHash() {
const v = encodeURIComponent(window.__SHINE_BUILD_HASH__ || 'dev');
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/components.css', './styles/network-graph.css', './styles/buttons-white.css'];
const cssFiles = ['./styles/main.css', './styles/layout.css', './styles/app-shell.css', './styles/buttons-white.css', './styles/components/topbar.css', './styles/components/dropdown-menu.css', './styles/components/primitives.css', './styles/components/tabs.css', './styles/components/avatar.css', './styles/components/modal.css', './styles/components/attachments.css', './styles/components/emoji-picker.css', './styles/components/call-ui.css', './styles/components/scroll-to-bottom.css', './styles/components/overflow-dots.css', './styles/components/toolbar.css', './styles/features/preauth.css', './styles/features/messages.css', './styles/features/chat.css', './styles/features/channels-common.css', './styles/features/channels-list.css', './styles/features/channel.css', './styles/features/channel-thread.css', './styles/features/notifications.css', './styles/features/network.css', './styles/features/profile.css', './styles/features/settings.css', './styles/features/devices.css', './styles/features/developer-tools.css', './styles/network-graph.css'];
cssFiles.forEach((file) => {
const link = document.createElement('link');
link.rel = 'stylesheet';
@@ -43,7 +43,9 @@ window.__SHINE_BUILD_HASH__ = '20260901134200';
<div id="topbar-slot" class="topbar-slot" hidden></div>
<main id="app-screen" class="screen-content"></main>
<div id="composer-slot" class="composer-slot" hidden></div>
<div id="toolbar-slot" class="toolbar-slot"></div>
<div id="toolbar-slot" class="toolbar-slot" hidden></div>
<div class="app-shell-fade app-shell-fade--top" aria-hidden="true"></div>
<div class="app-shell-fade app-shell-fade--bottom" aria-hidden="true"></div>
</div>
<div id="modal-root"></div>
<script>
+140 -32
View File
@@ -399,14 +399,88 @@ if (new URLSearchParams(window.location.search).get('keyboard-debug') === '1') {
syncDebug();
}
function clearSlot(slotEl, cssVarName) {
const MANAGED_SHELL_CLASSES = [
'app-shell--top-fade',
'app-shell--bottom-fade',
'app-shell--bottom-fade-composer',
'app-shell--bottom-fade-toolbar',
'app-shell--fade-edge',
'app-shell--content-under-topbar',
'app-shell--content-under-bottom',
'app-shell--scroll-nested',
'app-shell--scroll-locked',
'app-shell--scrollbar-hidden',
];
const MANAGED_SCREEN_CLASSES = [
'no-app-chrome',
'preauth-flow',
'filled-action-buttons',
'settings-bordered-actions',
];
const DEFAULT_SHELL_MODE = Object.freeze({
topFade: true,
bottomFade: false,
bottomFadeAnchor: 'composer',
fadeProfile: 'standard',
contentUnderTopbar: true,
contentUnderBottom: false,
scrollContainer: 'screen',
scrollbar: 'auto',
});
function normalizeShellMode(mode = {}, showAppChrome = true) {
const source = mode && typeof mode === 'object' ? mode : {};
const normalized = { ...DEFAULT_SHELL_MODE, ...source };
if (!showAppChrome) {
normalized.topFade = false;
normalized.bottomFade = false;
normalized.contentUnderTopbar = false;
normalized.contentUnderBottom = false;
}
normalized.bottomFadeAnchor = normalized.bottomFadeAnchor === 'toolbar' ? 'toolbar' : 'composer';
normalized.fadeProfile = normalized.fadeProfile === 'edge' ? 'edge' : 'standard';
normalized.scrollContainer = ['screen', 'nested', 'locked'].includes(normalized.scrollContainer)
? normalized.scrollContainer
: 'screen';
normalized.scrollbar = normalized.scrollbar === 'hidden' ? 'hidden' : 'auto';
return normalized;
}
function applyShellMode(mode, showAppChrome = true) {
if (!appShellEl) return normalizeShellMode(mode, showAppChrome);
const normalized = normalizeShellMode(mode, showAppChrome);
appShellEl.classList.toggle('app-shell--top-fade', Boolean(normalized.topFade));
appShellEl.classList.toggle('app-shell--bottom-fade', Boolean(normalized.bottomFade));
appShellEl.classList.toggle('app-shell--bottom-fade-composer', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'composer');
appShellEl.classList.toggle('app-shell--bottom-fade-toolbar', Boolean(normalized.bottomFade) && normalized.bottomFadeAnchor === 'toolbar');
appShellEl.classList.toggle('app-shell--fade-edge', normalized.fadeProfile === 'edge');
appShellEl.classList.toggle('app-shell--content-under-topbar', Boolean(normalized.contentUnderTopbar));
appShellEl.classList.toggle('app-shell--content-under-bottom', Boolean(normalized.contentUnderBottom));
appShellEl.classList.toggle('app-shell--scroll-nested', normalized.scrollContainer === 'nested');
appShellEl.classList.toggle('app-shell--scroll-locked', normalized.scrollContainer === 'locked');
appShellEl.classList.toggle('app-shell--scrollbar-hidden', normalized.scrollbar === 'hidden');
return normalized;
}
function resetShellMode() {
appShellEl?.classList.remove(...MANAGED_SHELL_CLASSES);
}
function resetManagedScreenClasses() {
screenEl?.classList.remove(...MANAGED_SCREEN_CLASSES);
}
function clearSlot(slotEl, cssVarName, presenceClass = '') {
if (!slotEl) return;
slotEl.innerHTML = '';
slotEl.hidden = true;
if (presenceClass) appShellEl?.classList.remove(presenceClass);
setShellMetricVar(cssVarName, 0);
}
function mountSlot(slotEl, cssVarName, node) {
function mountSlot(slotEl, cssVarName, node, presenceClass = '') {
if (!slotEl) return;
slotEl.innerHTML = '';
if (node instanceof Node) {
@@ -415,59 +489,89 @@ function mountSlot(slotEl, cssVarName, node) {
} else {
slotEl.hidden = true;
}
if (presenceClass) appShellEl?.classList.toggle(presenceClass, !slotEl.hidden);
setShellMetricVar(cssVarName, !slotEl.hidden ? slotEl.offsetHeight : 0);
}
function createChromeController(showAppChrome) {
function createChromeController(showAppChrome, initialShellMode = {}) {
let topbarNode = null;
let composerNode = null;
const cleanupOwnedNode = (node) => {
if (node && typeof node.cleanup === 'function') node.cleanup();
};
let shellMode = normalizeShellMode(initialShellMode, showAppChrome);
let disposed = false;
const apply = () => {
if (disposed) return;
applyShellMode(shellMode, showAppChrome);
if (!showAppChrome) {
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
return;
}
mountSlot(topbarEl, '--topbar-height', topbarNode);
mountSlot(composerEl, '--composer-height', composerNode);
mountSlot(topbarEl, '--topbar-height', topbarNode, 'app-shell--has-topbar');
mountSlot(composerEl, '--composer-height', composerNode, 'app-shell--has-composer');
topbarHeightObserver?.sync?.();
composerHeightObserver?.sync?.();
};
apply();
return {
setTopbar(node = null) {
topbarNode = node instanceof Node ? node : null;
const nextTopbar = node instanceof Node ? node : null;
if (topbarNode && topbarNode !== nextTopbar) cleanupOwnedNode(topbarNode);
topbarNode = nextTopbar;
apply();
},
setComposer(node = null) {
composerNode = node instanceof Node ? node : null;
const nextComposer = node instanceof Node ? node : null;
if (composerNode && composerNode !== nextComposer) cleanupOwnedNode(composerNode);
composerNode = nextComposer;
apply();
},
setShellMode(nextMode = {}) {
shellMode = normalizeShellMode({ ...shellMode, ...(nextMode || {}) }, showAppChrome);
apply();
},
clear() {
cleanupOwnedNode(topbarNode);
cleanupOwnedNode(composerNode);
topbarNode = null;
composerNode = null;
apply();
},
suspend() {
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
},
resume() {
apply();
},
dispose() {
cleanupOwnedNode(topbarNode);
cleanupOwnedNode(composerNode);
topbarNode = null;
composerNode = null;
disposed = true;
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
resetShellMode();
},
};
}
function clearKeepAliveEntries() {
currentCleanup = null;
currentChromeCleanup = null;
if (typeof currentCleanup === 'function') {
currentCleanup();
currentCleanup = null;
}
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
}
async function unlockHiddenDmAudio() {
@@ -1244,14 +1348,15 @@ function renderPageFailureFallback(pageId, error) {
wrap.append(card);
screenEl.append(wrap);
resetManagedScreenClasses();
screenEl.classList.toggle('no-app-chrome', false);
if (typeof currentChromeCleanup === 'function') {
currentChromeCleanup();
currentChromeCleanup = null;
}
clearSlot(topbarEl, '--topbar-height');
clearSlot(composerEl, '--composer-height');
toolbarEl.innerHTML = '';
clearSlot(topbarEl, '--topbar-height', 'app-shell--has-topbar');
clearSlot(composerEl, '--composer-height', 'app-shell--has-composer');
clearSlot(toolbarEl, '--toolbar-height');
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
}
@@ -1292,10 +1397,23 @@ function renderApp() {
currentChromeCleanup = null;
}
// Сначала полностью очищаем page-owned UI предыдущего маршрута, затем
// применяем новый shell mode и только после этого монтируем следующий экран.
screenEl.innerHTML = '';
resetManagedScreenClasses();
clearSlot(toolbarEl, '--toolbar-height');
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
try {
screenEl.innerHTML = '';
const chrome = createChromeController(showAppChrome);
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
currentChromeCleanup = () => chrome.dispose();
if (showAppChrome) {
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
toolbarHeightObserver?.sync?.();
}
const screen = page.render({ route, navigate, chrome });
if (!(screen instanceof Node)) {
throw new Error('Page render returned invalid node');
@@ -1309,16 +1427,6 @@ function renderApp() {
scrollToBottomControl?.cleanup();
};
screenEl.classList.toggle('no-app-chrome', !showAppChrome);
screenEl.classList.toggle('preauth-flow', PRE_AUTH_PAGES.includes(pageId));
screenEl.classList.toggle('filled-action-buttons', FILLED_ACTION_BUTTON_PAGE_IDS.has(pageId));
screenEl.classList.toggle('settings-bordered-actions', SETTINGS_BORDERED_ACTION_PAGE_IDS.has(pageId));
toolbarEl.innerHTML = '';
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
} catch (error) {
console.error('[renderApp] controlled fallback', error);
@@ -1333,9 +1441,9 @@ function refreshToolbarOnly() {
const showAppChrome = page.pageMeta?.showAppChrome !== false
&& !(pageId === 'language-view' && !state.session.isAuthorized);
toolbarEl.innerHTML = '';
clearSlot(toolbarEl, '--toolbar-height');
if (showAppChrome) {
toolbarEl.append(renderToolbar(page.pageMeta.id, navigate));
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
}
toolbarHeightObserver?.sync?.();
refreshConnectionUi();
+135 -60
View File
@@ -1,88 +1,157 @@
let activeDropdown = null;
function normalizeItems(items) {
const value = typeof items === 'function' ? items() : items;
return Array.isArray(value) ? value.filter(Boolean) : [];
}
function resolvePlacement({ placement, align }) {
if (placement === 'bottom-start' || placement === 'top-start') return 'left';
if (placement === 'bottom-end' || placement === 'top-end') return 'right';
return align === 'left' ? 'left' : 'right';
}
export function createDropdownMenu({
anchorEl,
items = [],
renderContent = null,
className = '',
minWidth = 210,
offset = 7,
align = 'right',
placement = 'bottom-end',
align = null,
leftShift = 0,
transparent = false,
dimBackground = true,
keepAnchorPressed = true,
onOpen = null,
onClose = null,
} = {}) {
let portal = null;
let menuEl = null;
let destroyed = false;
const close = () => {
const setAnchorOpen = (isOpen) => {
if (!anchorEl) return;
anchorEl.setAttribute('aria-haspopup', 'menu');
anchorEl.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
if (keepAnchorPressed && isOpen) anchorEl.dataset.open = 'true';
else delete anchorEl.dataset.open;
};
const close = ({ focusAnchor = false } = {}) => {
if (!portal) return;
portal.remove();
portal = null;
anchorEl?.setAttribute('aria-expanded', 'false');
menuEl = null;
if (activeDropdown === api) activeDropdown = null;
setAnchorOpen(false);
onClose?.();
if (focusAnchor) anchorEl?.focus?.();
};
const position = () => {
if (!portal || !anchorEl) return;
if (!portal || !menuEl || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const margin = 10;
const menuWidth = portal.offsetWidth || minWidth;
const baseLeft = align === 'left' ? rect.left : rect.right - menuWidth;
const menuWidth = menuEl.offsetWidth || minWidth;
const side = resolvePlacement({ placement, align });
const baseLeft = side === 'left' ? rect.left : rect.right - menuWidth;
const desiredLeft = baseLeft - Number(leftShift || 0);
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, desiredLeft));
let top = rect.bottom + offset;
const menuHeight = portal.offsetHeight || 180;
if (top + menuHeight > window.innerHeight - margin) {
const menuHeight = menuEl.offsetHeight || 180;
const prefersTop = String(placement || '').startsWith('top-');
let top = prefersTop ? rect.top - menuHeight - offset : rect.bottom + offset;
if (!prefersTop && top + menuHeight > window.innerHeight - margin) {
top = Math.max(margin, rect.top - menuHeight - offset);
} else if (prefersTop && top < margin) {
top = Math.min(window.innerHeight - menuHeight - margin, rect.bottom + offset);
}
portal.style.left = `${Math.round(left)}px`;
portal.style.top = `${Math.round(top)}px`;
menuEl.style.left = `${Math.round(left)}px`;
menuEl.style.top = `${Math.round(top)}px`;
};
const open = () => {
if (!anchorEl || portal) return;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const menu = document.createElement('div');
menu.className = `dm-head-menu dm-head-menu--portal shared-dropdown-menu ${className}`.trim();
menu.setAttribute('role', 'menu');
menu.style.minWidth = `${minWidth}px`;
items.forEach((item) => {
if (item?.divider) {
const appendItems = () => {
normalizeItems(items).forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
menu.append(divider);
divider.className = 'dropdown-menu__divider';
divider.setAttribute('role', 'separator');
menuEl.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `dm-head-menu-item shared-dropdown-menu__item${item?.selected ? ' is-selected' : ''}${item?.danger ? ' destructive' : ''}`;
btn.setAttribute('role', 'menuitem');
if (item?.iconHtml) {
const button = document.createElement('button');
button.type = 'button';
button.className = `dropdown-menu__item${item.selected ? ' is-selected' : ''}${item.danger ? ' is-danger' : ''}${item.className ? ` ${item.className}` : ''}`;
button.setAttribute('role', 'menuitem');
button.disabled = Boolean(item.disabled);
if (item.iconHtml) {
const icon = document.createElement('span');
icon.className = 'shared-dropdown-menu__icon';
icon.className = 'dropdown-menu__icon';
icon.innerHTML = item.iconHtml;
btn.append(icon);
} else if (item?.iconSrc) {
button.append(icon);
} else if (item.iconSrc) {
const icon = document.createElement('img');
icon.className = 'dropdown-menu__icon';
icon.src = item.iconSrc;
icon.alt = '';
icon.setAttribute('aria-hidden', 'true');
btn.append(icon);
button.append(icon);
}
const label = document.createElement('span');
label.textContent = String(item?.label || '');
btn.append(label);
btn.addEventListener('click', (event) => {
label.textContent = String(item.label || '');
button.append(label);
button.addEventListener('click', async (event) => {
event.preventDefault();
event.stopPropagation();
if (button.disabled) return;
close();
item?.action?.();
await item.action?.();
});
menu.append(btn);
menuEl.append(button);
});
};
menu.addEventListener('click', (event) => event.stopPropagation());
document.body.append(menu);
portal = menu;
anchorEl.setAttribute('aria-expanded', 'true');
const open = () => {
if (destroyed || !anchorEl || portal) return;
if (activeDropdown && activeDropdown !== api) activeDropdown.close();
portal = document.createElement('div');
portal.className = `dropdown-portal${dimBackground ? ' dropdown-portal--dim' : ''}`;
if (dimBackground) {
const backdrop = document.createElement('div');
backdrop.className = 'dropdown-backdrop';
backdrop.setAttribute('aria-hidden', 'true');
backdrop.addEventListener('pointerdown', (event) => {
if (event.target === backdrop) close();
});
portal.append(backdrop);
}
menuEl = document.createElement('div');
menuEl.className = `dropdown-menu${transparent ? ' dropdown-menu--transparent' : ''}${className ? ` ${className}` : ''}`;
menuEl.setAttribute('role', 'menu');
menuEl.style.minWidth = `${minWidth}px`;
if (typeof renderContent === 'function') {
const content = renderContent({ close, menuEl });
if (content instanceof Node) menuEl.append(content);
} else {
appendItems();
}
menuEl.addEventListener('pointerdown', (event) => event.stopPropagation());
menuEl.addEventListener('click', (event) => event.stopPropagation());
portal.append(menuEl);
document.body.append(portal);
activeDropdown = api;
setAnchorOpen(true);
onOpen?.();
position();
};
@@ -97,43 +166,49 @@ export function createDropdownMenu({
event.stopPropagation();
toggle();
};
const onOutsideClick = (event) => {
const onOutsidePointerDown = (event) => {
if (!portal) return;
if (portal.contains(event.target) || anchorEl?.contains(event.target)) return;
close();
};
const onPeerDropdownOpen = (event) => {
if (!portal || event?.detail?.owner === anchorEl) return;
if (menuEl?.contains(event.target) || anchorEl?.contains(event.target)) return;
close();
};
const onKeydown = (event) => {
if (event.key !== 'Escape' || !portal) return;
close();
anchorEl?.focus();
close({ focusAnchor: true });
};
const onNavigation = () => close();
const onViewportChange = () => position();
anchorEl?.setAttribute('aria-haspopup', 'menu');
anchorEl?.setAttribute('aria-expanded', 'false');
setAnchorOpen(false);
anchorEl?.addEventListener('click', onAnchorClick);
document.addEventListener('click', onOutsideClick);
document.addEventListener('pointerdown', onOutsidePointerDown, true);
document.addEventListener('keydown', onKeydown);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', position, { passive: true });
window.addEventListener('scroll', position, { passive: true, capture: true });
window.addEventListener('popstate', onNavigation);
window.addEventListener('hashchange', onNavigation);
window.addEventListener('resize', onViewportChange, { passive: true });
window.addEventListener('scroll', onViewportChange, { passive: true, capture: true });
return {
const api = {
open,
close,
toggle,
position,
get isOpen() {
return Boolean(portal);
},
destroy() {
if (destroyed) return;
destroyed = true;
close();
anchorEl?.removeEventListener('click', onAnchorClick);
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('pointerdown', onOutsidePointerDown, true);
document.removeEventListener('keydown', onKeydown);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', position);
window.removeEventListener('scroll', position, true);
window.removeEventListener('popstate', onNavigation);
window.removeEventListener('hashchange', onNavigation);
window.removeEventListener('resize', onViewportChange);
window.removeEventListener('scroll', onViewportChange, true);
setAnchorOpen(false);
},
};
return api;
}
-58
View File
@@ -1,58 +0,0 @@
export function renderHeader({ title = '', centerNode = null, leftAction, leftLabel = '', rightActions = [] }) {
const wrap = document.createElement('header');
wrap.className = 'page-header app-topbar-shell';
const left = document.createElement('div');
left.className = 'header-left';
if (leftAction) {
const btn = document.createElement('button');
btn.type = 'button';
const rawLabel = String(leftAction.label || '').trim();
const isBackAction = rawLabel === '←' || rawLabel === '<' || rawLabel === '';
btn.className = `icon-btn${isBackAction ? ' header-back-btn' : ''}`;
btn.textContent = isBackAction ? '←' : rawLabel;
if (isBackAction) {
btn.setAttribute('aria-label', leftAction.ariaLabel || 'Назад');
btn.title = leftAction.title || 'Назад';
}
btn.addEventListener('click', leftAction.onClick);
left.append(btn);
}
if (leftLabel) {
const label = document.createElement('span');
label.className = 'header-left-label';
label.textContent = leftLabel;
left.append(label);
}
const center = document.createElement('div');
center.className = 'header-center';
if (centerNode instanceof Node) {
center.append(centerNode);
} else {
const h1 = document.createElement('h1');
h1.className = 'page-title';
h1.textContent = title;
center.append(h1);
}
const right = document.createElement('div');
right.className = 'header-actions';
rightActions.forEach((action) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = `icon-btn${action.className ? ` ${action.className}` : ''}`;
if (action.title) btn.title = action.title;
if (action.ariaLabel) btn.setAttribute('aria-label', action.ariaLabel);
if (action.iconNode instanceof Node) {
btn.append(action.iconNode);
} else {
btn.textContent = action.label;
}
btn.addEventListener('click', action.onClick);
right.append(btn);
});
wrap.append(left, center, right);
return wrap;
}
+112
View File
@@ -0,0 +1,112 @@
import { createDropdownMenu } from './dropdown-menu.js';
function appendNode(target, node) {
if (!target || !(node instanceof Node)) return;
target.append(node);
}
function createActionButton(action = {}, cleanupFns) {
const button = document.createElement('button');
button.type = 'button';
button.className = `icon-btn topbar__action${action.className ? ` ${action.className}` : ''}`;
if (action.title) button.title = action.title;
if (action.ariaLabel) button.setAttribute('aria-label', action.ariaLabel);
if (action.id) button.dataset.action = action.id;
if (action.iconNode instanceof Node) {
button.append(action.iconNode);
} else {
button.textContent = String(action.label ?? '');
}
if (action.menu) {
const menu = createDropdownMenu({
anchorEl: button,
transparent: true,
dimBackground: true,
keepAnchorPressed: true,
...(typeof action.menu === 'object' ? action.menu : {}),
});
cleanupFns.add(() => menu.destroy());
} else if (typeof action.onClick === 'function') {
button.addEventListener('click', action.onClick);
}
return button;
}
export function createTopBar({
title = '',
className = '',
left = null,
center = null,
back = null,
leftLabel = '',
actions = [],
} = {}) {
const cleanupFns = new Set();
const topbar = document.createElement('header');
topbar.className = `topbar${className ? ` ${className}` : ''}`;
const leftSlot = document.createElement('div');
leftSlot.className = 'topbar__left';
const backAction = back;
if (backAction?.visible !== false && typeof backAction?.onClick === 'function') {
const button = document.createElement('button');
button.type = 'button';
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
button.textContent = '←';
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
button.title = backAction.title || 'Назад';
button.addEventListener('click', backAction.onClick);
leftSlot.append(button);
}
appendNode(leftSlot, left);
if (leftLabel) {
const label = document.createElement('span');
label.className = 'topbar__left-label';
label.textContent = leftLabel;
leftSlot.append(label);
}
const centerSlot = document.createElement('div');
centerSlot.className = 'topbar__center';
const resolvedCenter = center;
if (resolvedCenter instanceof Node) {
centerSlot.append(resolvedCenter);
} else {
const heading = document.createElement('h1');
heading.className = 'topbar__title';
heading.textContent = String(title || '');
centerSlot.append(heading);
}
const rightSlot = document.createElement('div');
rightSlot.className = 'topbar__right';
const normalizedActions = actions;
normalizedActions.forEach((action) => {
rightSlot.append(createActionButton(action, cleanupFns));
});
topbar.append(leftSlot, centerSlot, rightSlot);
topbar.addCleanup = (cleanup) => {
if (typeof cleanup === 'function') cleanupFns.add(cleanup);
return cleanup;
};
topbar.cleanup = () => {
for (const cleanup of cleanupFns) {
try {
cleanup();
} catch (error) {
console.warn('[TopBar] cleanup failed', error);
}
}
cleanupFns.clear();
};
return topbar;
}
+7 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { base64ToBytes, bytesToBase58, publicKeyB64FromPkcs8Ed25519 } from '../services/crypto-utils.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
@@ -259,7 +259,7 @@ function createPasswordModal() {
};
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -427,7 +427,7 @@ export function render({ navigate }) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = items.map((item) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(item.login)}" data-url="${escapeHtml(item.url || '')}">
@${escapeHtml(item.login)}
<span class="meta-muted" style="display:block; margin-top:0.2rem;">${escapeHtml(item.url || 'URL не указан')}</span>
</button>`
@@ -633,11 +633,11 @@ export function render({ navigate }) {
}
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Сервер доступа',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
introCard,
listCard,
addCard,
+6 -8
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -78,16 +78,14 @@ function shortAvatarBlockchainAddress(value) {
return raw.slice(-24);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Создание канала',
leftAction: { label: '<', onClick: () => navigate('channels-list') },
}),
);
back: { label: '<', onClick: () => navigate('channels-list') },
}));
const form = document.createElement('form');
form.className = 'card stack';
@@ -97,7 +95,7 @@ export function render({ navigate }) {
<div class="channel-create-avatar-side">
<div class="channel-create-avatar-status-row">
<div class="channel-create-avatar-status" id="channel-avatar-status"></div>
<button type="button" class="channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
<button type="button" class="ui-button channel-avatar-remove-btn" id="channel-avatar-remove" title="Убрать аватар" aria-label="Убрать аватар">✕</button>
</div>
<button type="button" class="secondary-btn" id="channel-avatar-btn">Выбрать аватар</button>
</div>
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
@@ -47,16 +47,14 @@ function createDebounced(fn, delayMs = 240) {
};
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--add';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Новый персональный публичный чат',
leftAction: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}),
);
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
}));
const form = document.createElement('form');
form.className = 'card stack';
@@ -119,7 +117,7 @@ export function render({ navigate }) {
rows.slice(0, 8).forEach((login) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = String(login);
btn.addEventListener('click', () => {
selectedCanonicalLogin = String(login);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { clearAppLogEntries, getAppLogEntries } from '../state.js';
export const pageMeta = { id: 'app-log-view', title: 'Лог приложения' };
@@ -11,16 +11,14 @@ function formatTime(ts) {
}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Лог приложения',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
const controls = document.createElement('div');
controls.className = 'card row';
+45 -57
View File
@@ -7,16 +7,12 @@ import {
} from '../components/arweave-attachment-manager.js';
import { formatBytes } from '../services/attachment-format.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createTopBar } from '../components/topbar.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import { state } from '../state.js';
export const pageMeta = { id: 'arweave-uploads-view', title: 'Загрузка файлов' };
function closeUploadsMenu(controls) {
const menu = controls?.querySelector?.('[data-menu="true"]');
if (menu) menu.hidden = true;
}
function setStatusBadge(node, status, label) {
if (!node) return;
node.className = `ar-attachment-status ar-attachment-status--${status || 'pending'}`;
@@ -64,25 +60,10 @@ function renderTile(item, index) {
return tile;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack arweave-uploads-screen';
const controls = document.createElement('div');
controls.className = 'arweave-uploads-toolbar';
controls.innerHTML = `
<button class="secondary-btn arweave-uploads-back" type="button" data-action="back" aria-label="Назад">←</button>
<div class="arweave-uploads-title">Загрузка файлов в блокчейн</div>
<button class="primary-btn arweave-uploads-add" type="button" data-action="upload" aria-label="Добавить файл">+</button>
<button class="secondary-btn arweave-uploads-menu-btn" type="button" data-action="menu" aria-label="Меню"></button>
<div class="arweave-uploads-menu" data-menu="true" hidden>
<button class="shine-btn shine-btn--settings" type="button" data-action="upload-menu">Добавить файл</button>
<button class="shine-btn shine-btn--settings" type="button" data-action="clear">Очистить историю</button>
<button class="shine-btn shine-btn--settings" type="button" data-action="help">Справка</button>
</div>
`;
controls.querySelector('[data-action="menu"]')?.append(createOverflowDots());
const statusLine = document.createElement('p');
statusLine.className = 'meta-muted inline-error';
@@ -91,7 +72,6 @@ export function render({ navigate }) {
const uploadFile = async () => {
statusLine.textContent = '';
closeUploadsMenu(controls);
try {
await openArweaveAttachmentManager({
login: state.session.login,
@@ -134,42 +114,50 @@ export function render({ navigate }) {
}, 0);
}
controls.querySelector('[data-action="upload"]')?.addEventListener('click', () => {
void uploadFile();
const topbar = createTopBar({
title: 'Загрузка файлов в блокчейн',
back: { onClick: () => navigate('settings-view') },
actions: [
{
label: '+',
title: 'Добавить файл',
ariaLabel: 'Добавить файл',
className: 'arweave-uploads-add',
onClick: () => { void uploadFile(); },
},
{
iconNode: createOverflowDots(),
title: 'Меню',
ariaLabel: 'Меню загрузок',
className: 'arweave-uploads-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Добавить файл', action: () => uploadFile() },
{
label: 'Очистить историю',
action: () => {
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
},
},
{
label: 'Справка',
action: () => window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
),
},
],
},
},
],
});
controls.querySelector('[data-action="upload-menu"]')?.addEventListener('click', () => {
void uploadFile();
});
controls.querySelector('[data-action="back"]')?.addEventListener('click', () => navigate('settings-view'));
controls.querySelector('[data-action="menu"]')?.addEventListener('click', (event) => {
event.stopPropagation();
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = !menu.hidden;
});
controls.querySelector('[data-action="clear"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
const confirmed = window.confirm('Очистить журнал загруженных файлов этой сессии?');
if (!confirmed) return;
clearArweaveAttachmentHistory(state.session.login);
renderHistory();
});
controls.querySelector('[data-action="help"]')?.addEventListener('click', () => {
const menu = controls.querySelector('[data-menu="true"]');
if (menu) menu.hidden = true;
window.alert(
'Здесь вы можете заранее добавить файл в Arweave или через Turbo. По умолчанию сразу выбрана загрузка через Turbo, а маленькие файлы пока загружаются там бесплатно.\n\n'
+ 'Потом при создании сообщения можно открыть историю загрузок и выбрать уже загруженный файл по txId. '
+ 'Если файл только что загружен, gateway может несколько минут его не отдавать, поэтому статус может быть “ещё обновляется”.'
);
});
screen.addEventListener('click', (event) => {
if (controls.contains(event.target)) return;
closeUploadsMenu(controls);
});
screen.append(controls, statusLine, list);
chrome?.setTopbar(topbar);
screen.append(statusLine, list);
renderHistory();
return screen;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { navigateBack } from '../router.js';
import { makeShineChannelRootRoute, makeShineChannelShortRoute } from '../services/shine-routes.js';
@@ -63,9 +63,9 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel-about';
const topbar = renderHeader({
const topbar = createTopBar({
title: 'О канале',
leftAction: {
back: {
label: '←',
onClick: () => {
if (window.history.length > 1) {
+130 -65
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, getMessageReactionState, setMessageReactionState, state } from '../state.js';
import { captureClientError } from '../services/client-error-reporter.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -23,7 +23,7 @@ import {
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { extractLoginFromBlockchainName, makeProfileRoute, makeShineChannelRoute, makeShineMessageRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'channel-thread-view', title: 'Тред' };
export const pageMeta = { id: 'channel-thread-view', title: 'Тред', shellMode: { scrollbar: 'hidden' } };
const MSG_SUBTYPE_TEXT_POST = 10;
const MSG_SUBTYPE_TEXT_RATING = 30;
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
@@ -437,7 +437,7 @@ function buildBlockchainDetails({ target, authorLogin, timestampMs, text, raw, l
};
}
function openBlockchainDetailsModal(details) {
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -471,6 +471,7 @@ function openBlockchainDetailsModal(details) {
});
root.querySelector('#thread-blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#thread-blockchain-details-raw')?.addEventListener('click', () => {
@@ -495,7 +496,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -508,7 +509,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -569,9 +570,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
@@ -589,10 +592,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -602,7 +606,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
if (textEl) textEl.focus();
}
function openRepostModal({ navigate, channels = [], onSubmit }) {
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -668,8 +672,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
@@ -716,7 +722,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="thread-edit-modal">
@@ -749,16 +755,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#thread-edit-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
@@ -798,7 +808,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
authorTile.className = 'ui-button channel-message-author-tile';
const headRow = document.createElement('div');
headRow.className = 'channel-message-head-row';
@@ -821,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -844,7 +854,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof handlers?.onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -865,7 +875,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.className = 'ui-button deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${author}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -915,7 +925,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item thread-like-btn';
likeButton.className = 'ui-button channel-action-item thread-like-btn';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">${isLiked ? '❤️' : '🤍'}</span>
@@ -949,7 +959,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item thread-reply-btn';
replyButton.className = 'ui-button channel-action-item thread-reply-btn';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -962,6 +972,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
openReplyModal({
navigate: handlers.navigate,
onSubmit: async (textValue) => handlers.onReply(target, textValue),
isActive: handlers.isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
@@ -969,7 +980,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item thread-share-btn';
shareButton.className = 'ui-button channel-action-item thread-share-btn';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
@@ -987,7 +998,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
if (repostTarget) {
const originalButton = document.createElement('button');
originalButton.type = 'button';
originalButton.className = 'channel-action-item';
originalButton.className = 'ui-button channel-action-item';
originalButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
@@ -1009,7 +1020,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -1025,13 +1036,13 @@ function renderNodeCard(node, heading, handlers, localNumber) {
raw: node,
localNumber,
msgSubType,
}));
}), { isActive: handlers.isActive });
});
actions.append(detailsButton);
if (isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.className = 'ui-button channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -1086,11 +1097,12 @@ function renderDescendants(items, handlers, nextNumber, depth = 0) {
return wrap;
}
function applyPendingScroll(screen, routeKey) {
function applyPendingScroll(screen, routeKey, shouldContinue = () => true) {
const target = pendingThreadScroll.get(routeKey);
if (!target) return;
const doScroll = () => {
if (!shouldContinue()) return;
if (target === '__LAST_REPLY__') {
const cards = screen.querySelectorAll('.thread-block--replies [data-message-key]');
const last = cards[cards.length - 1];
@@ -1108,7 +1120,7 @@ function applyPendingScroll(screen, routeKey) {
}
};
setTimeout(doScroll, 20);
return window.setTimeout(doScroll, 20);
}
function renderSkeleton(screen) {
@@ -1120,15 +1132,18 @@ function renderSkeleton(screen) {
}
export function render({ navigate, route, chrome }) {
const selector = parseThreadSelector(route);
let selector = parseThreadSelector(route);
const channelDisplayName = resolveChannelDisplayName(selector?.channel);
const routeKey = `${selector?.message?.blockchainName || ''}:${selector?.message?.blockNumber || ''}:${selector?.message?.blockHash || ''}`;
let activeResolvedChannelLabel = channelDisplayName;
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--thread';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
const refreshTimers = new Set();
const threadHeaderButton = document.createElement('button');
threadHeaderButton.type = 'button';
@@ -1136,10 +1151,10 @@ export function render({ navigate, route, chrome }) {
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
const header = renderHeader({
centerNode: threadHeaderButton,
leftAction: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
rightActions: [
const header = createTopBar({
center: threadHeaderButton,
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
actions: [
{
label: '↑',
title: 'К списку каналов',
@@ -1156,19 +1171,12 @@ export function render({ navigate, route, chrome }) {
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const rerender = () => {
try {
const current = document.querySelector('section.channels-screen--thread');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
} catch (error) {
logThreadRuntimeError('rerender', error, { routePath: window.location.pathname });
}
const ensureActive = () => {
if (disposed) throw new Error('Экран треда уже закрыт.');
};
const showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
@@ -1191,6 +1199,7 @@ export function render({ navigate, route, chrome }) {
const handlers = {
navigate,
isActive: () => !disposed,
onToggleLike: async (target, action) => {
const actionKey = makeReactionActionKey(target);
if (!actionKey) throw new Error('Некорректная ссылка на сообщение для реакции.');
@@ -1208,12 +1217,14 @@ export function render({ navigate, route, chrome }) {
await authService.addBlockLike({ login, storagePwd, message: target });
}
if (disposed) return;
setMessageReactionState(target, nextReaction);
softHaptic(10);
rerender();
void refresh();
} catch (error) {
if (disposed) return;
setMessageReactionState(target, previousReaction || 'unliked');
rerender();
void refresh();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -1222,24 +1233,27 @@ export function render({ navigate, route, chrome }) {
onReply: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: target, text: textValue });
ensureActive();
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Ответ отправлен');
showStatus('');
rerender();
void refresh();
},
onRating: async (target, textValue) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: target, text: textValue });
ensureActive();
pendingThreadScroll.set(routeKey, '__LAST_REPLY__');
softHaptic(15);
showToast('Оценка отправлена');
showStatus('');
rerender();
void refresh();
},
onRepost: async (target) => {
const { login, storagePwd } = requireSigningSession();
const feed = await authService.listSubscriptionsFeed(login, 1000);
if (disposed) return;
const channels = (Array.isArray(feed?.ownedChannels) ? feed.ownedChannels : [])
.map((row) => {
const selectorRow = {
@@ -1264,6 +1278,7 @@ export function render({ navigate, route, chrome }) {
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
@@ -1272,6 +1287,7 @@ export function render({ navigate, route, chrome }) {
message: target,
text,
});
ensureActive();
softHaptic(12);
showToast('Репост опубликован');
showStatus('');
@@ -1287,6 +1303,7 @@ export function render({ navigate, route, chrome }) {
text: 'Сообщение из треда SHiNE',
url: buildAbsoluteRouteUrl(routePath),
});
if (disposed) return;
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'copied' || result === 'shared') softHaptic(10);
@@ -1320,33 +1337,69 @@ export function render({ navigate, route, chrome }) {
isChannelPost: meta?.isChannelPost === true,
channel: selector?.channel || null,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
showStatus('');
rerender();
void refresh();
},
};
screen.append(statusBox);
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
return screen;
}
const clearContent = () => {
for (const timerId of refreshTimers) window.clearTimeout(timerId);
refreshTimers.clear();
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
const skeleton = renderSkeleton(screen);
const clearOwnedModal = () => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return;
if (modalRoot.querySelector([
'#thread-blockchain-details-modal',
'#thread-edit-modal',
'#thread-history-modal',
'#thread-reply-modal',
'#thread-repost-modal',
].join(','))) {
modalRoot.innerHTML = '';
}
};
const trackTimer = (timerId) => {
if (timerId) refreshTimers.add(timerId);
return timerId;
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
showStatus('');
selector = parseThreadSelector(route);
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
threadHeaderButton.textContent = 'Тред в канале: ...';
threadHeaderButton.disabled = true;
threadHeaderButton.onclick = null;
if (!selector) {
const invalid = document.createElement('div');
invalid.className = 'card meta-muted';
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
screen.append(invalid);
return;
}
const skeleton = renderSkeleton(screen);
(async () => {
try {
let resolvedMessage = selector.message;
if (selector.short?.ownerBlockchainName && selector.short?.channelName) {
const ownFeed = await authService.listSubscriptionsFeed(state.session.login, 1000);
if (disposed || seq !== refreshSeq) return;
const allRows = [
...(Array.isArray(ownFeed?.ownedChannels) ? ownFeed.ownedChannels : []),
...(Array.isArray(ownFeed?.followedUsersChannels) ? ownFeed.followedUsersChannels : []),
@@ -1369,6 +1422,7 @@ export function render({ navigate, route, chrome }) {
if (!channel && !looksLikeBlockchainName(ownerRaw)) {
try {
const ownerUser = await authService.getUser(ownerRaw);
if (disposed || seq !== refreshSeq) return;
const ownerBch = String(ownerUser?.blockchainName || '').trim().toLowerCase();
if (ownerBch) {
channel = allRows.find((item) => (
@@ -1383,6 +1437,7 @@ export function render({ navigate, route, chrome }) {
if (!channel && ownerLoginFromBch) {
try {
const ownerFeed = await authService.listSubscriptionsFeed(ownerLoginFromBch, 500);
if (disposed || seq !== refreshSeq) return;
const ownerRows = Array.isArray(ownerFeed?.ownedChannels) ? ownerFeed.ownedChannels : [];
channel = ownerRows.find((item) => (
String(item?.channel?.ownerBlockchainName || '').trim().toLowerCase() === ownerNormalized
@@ -1412,6 +1467,7 @@ export function render({ navigate, route, chrome }) {
}
const payload = await authService.getMessageThread(resolvedMessage, 20, 2, 50, state.session.login);
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
const ancestors = Array.isArray(payload?.ancestors) ? payload.ancestors : [];
@@ -1437,6 +1493,7 @@ export function render({ navigate, route, chrome }) {
let resolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
if (!resolvedChannelLabel && selector?.channel?.ownerBlockchainName && selector?.channel?.channelRootBlockNumber != null) {
resolvedChannelLabel = await resolveChannelDisplayNameFromServer(selector.channel);
if (disposed || seq !== refreshSeq) return;
}
activeResolvedChannelLabel = resolvedChannelLabel;
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
@@ -1453,10 +1510,10 @@ export function render({ navigate, route, chrome }) {
};
}
let seq = 0;
let localSeq = 0;
const nextNumber = () => {
seq += 1;
return seq;
localSeq += 1;
return localSeq;
};
let ancestorsWrap = null;
@@ -1506,25 +1563,33 @@ export function render({ navigate, route, chrome }) {
if (focusWrap) screen.append(focusWrap);
screen.append(descendantsWrap);
applyPendingScroll(screen, routeKey);
trackTimer(applyPendingScroll(screen, routeKey, () => !disposed && seq === refreshSeq));
const hasPendingScroll = pendingThreadScroll.has(routeKey);
if (!hasPendingScroll && focusWrap) {
setTimeout(() => {
trackTimer(window.setTimeout(() => {
if (disposed || seq !== refreshSeq || !focusWrap.isConnected) return;
focusWrap.scrollIntoView({ behavior: 'auto', block: 'start' });
}, 20);
}, 20));
}
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
const failed = document.createElement('div');
failed.className = 'card meta-muted';
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
screen.append(failed);
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
};
void refresh();
return screen;
}
+216 -95
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
getMessageReactionState,
@@ -34,7 +34,7 @@ import {
} from '../services/shine-routes.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channel-view', title: 'Канал' };
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden' } };
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const MSG_SUBTYPE_TEXT_RATING = 30;
@@ -775,7 +775,7 @@ function buildBlockchainDetails({ messageRef, authorLogin, timestampMs, text, ra
};
}
function openBlockchainDetailsModal(details) {
function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
const root = document.getElementById('modal-root');
const rawText = JSON.stringify(details.raw || details, null, 2);
root.innerHTML = `
@@ -809,6 +809,7 @@ function openBlockchainDetailsModal(details) {
});
root.querySelector('#blockchain-details-copy')?.addEventListener('click', async () => {
await copyTextToClipboard(rawText);
if (!isActive()) return;
showToast('Данные блокчейна скопированы');
});
root.querySelector('#blockchain-details-raw')?.addEventListener('click', () => {
@@ -824,7 +825,7 @@ function renderDraftAttachments(container, attachments) {
(Array.isArray(attachments) ? attachments : []).forEach((item, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'draft-attachment-chip';
button.className = 'ui-button draft-attachment-chip';
button.textContent = `${item.name} · ${item.ar}`;
button.title = 'Нажмите, чтобы убрать вложение';
button.addEventListener('click', () => {
@@ -837,7 +838,7 @@ function renderDraftAttachments(container, attachments) {
});
}
function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
function openReplyModal({ onSubmit, navigate, mode = 'reply', isActive = () => true }) {
const isRating = mode === 'rating';
const title = isRating ? 'Оценка' : 'Ответ';
const placeholder = isRating ? 'Текст оценки' : 'Текст ответа';
@@ -898,9 +899,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
try {
await onSubmit(composeMessageWithAttachments(text, attachments));
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, submitError);
}
@@ -918,10 +921,11 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -931,7 +935,7 @@ function openReplyModal({ onSubmit, navigate, mode = 'reply' }) {
if (textEl) textEl.focus();
}
function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
function openStatusActionCommentModal({ title, submitLabel, onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-status-action-modal">
@@ -973,8 +977,10 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit(String(textEl?.value || '').trim());
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сохранить действие.');
}
@@ -984,7 +990,7 @@ function openStatusActionCommentModal({ title, submitLabel, onSubmit }) {
if (textEl) textEl.focus();
}
function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
function openStatusActionMenuModal({ targetLabel, options = [], onSelect, isActive = () => true }) {
const root = document.getElementById('modal-root');
const rows = (Array.isArray(options) ? options : [])
.map((item, index) => `
@@ -1017,6 +1023,7 @@ function openStatusActionMenuModal({ targetLabel, options = [], onSelect }) {
const option = options[idx];
if (!option) return;
close();
if (!isActive()) return;
await onSelect(option);
});
});
@@ -1042,8 +1049,12 @@ function flashAndScrollToMessage(messageRef) {
if (!target) return false;
target.classList.remove('is-focus-flash');
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => target.classList.add('is-focus-flash'), 60);
window.setTimeout(() => target.classList.remove('is-focus-flash'), 1800);
window.setTimeout(() => {
if (target.isConnected) target.classList.add('is-focus-flash');
}, 60);
window.setTimeout(() => {
if (target.isConnected) target.classList.remove('is-focus-flash');
}, 1800);
return true;
}
@@ -1097,7 +1108,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
const parsed = parseMessageAttachments(post.body);
const item = document.createElement('button');
item.type = 'button';
item.className = 'entrypoint-history-item';
item.className = 'ui-button entrypoint-history-item';
item.innerHTML = `
<strong>${escapeHtml(post.timestampMs ? new Date(post.timestampMs).toLocaleString('ru-RU') : 'Без даты')}</strong>
<span>#${escapeHtml(post.localNumber || '—')}</span>
@@ -1116,7 +1127,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
});
}
function openRepostModal({ navigate, channels = [], onSubmit }) {
function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => true }) {
const root = document.getElementById('modal-root');
const options = (Array.isArray(channels) ? channels : [])
.filter((item) => item?.selector?.ownerBlockchainName && Number.isFinite(Number(item?.selector?.channelRootBlockNumber)))
@@ -1180,8 +1191,10 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
errorEl.textContent = '';
try {
await onSubmit({ channel: channels[idx].selector, text });
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось сделать репост.');
}
@@ -1190,7 +1203,7 @@ function openRepostModal({ navigate, channels = [], onSubmit }) {
if (textEl) textEl.focus();
}
function openAddMessageModal({ channelName, onSubmit, navigate }) {
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="channel-message-modal">
@@ -1258,9 +1271,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
text: composeMessageWithAttachments(body, attachments),
msgSubType,
});
if (!isActive()) return;
attachments.forEach((attachment) => markArweaveAttachmentPlaced(state.session.login, attachment));
close();
} catch (error) {
if (!isActive()) return;
setBusy(false);
errorEl.textContent = toUserMessage(error, 'Не удалось отправить сообщение.');
}
@@ -1278,10 +1293,11 @@ function openAddMessageModal({ channelName, onSubmit, navigate }) {
gateway: state.entrySettings.arweaveServer,
selectedTxIds: attachments.map((attachment) => attachment.ar),
});
if (!item) return;
if (!isActive() || !item) return;
attachments.push(item);
renderDraftAttachments(attachmentsEl, attachments);
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось добавить вложение.');
}
});
@@ -1327,7 +1343,7 @@ function openMessageHistoryModal({ versions = [], title = 'История изм
});
}
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete }) {
function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave, onDelete, isActive = () => true }) {
const root = document.getElementById('modal-root');
root.innerHTML = `
<div class="modal" id="edit-message-modal">
@@ -1361,16 +1377,20 @@ function openEditMessageModal({ initialText = '', allowEmptyText = false, onSave
}
try {
await onSave(value);
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось изменить сообщение.');
}
});
root.querySelector('#edit-message-delete')?.addEventListener('click', async () => {
try {
await onDelete();
if (!isActive()) return;
close();
} catch (error) {
if (!isActive()) return;
errorEl.textContent = toUserMessage(error, 'Не удалось удалить сообщение.');
}
});
@@ -1726,7 +1746,7 @@ function applyPendingScroll(screen, routeKey, forceBottom = false) {
}
};
setTimeout(doScroll, 20);
return window.setTimeout(doScroll, 20);
}
function mapChannelMetaEvent(event, fallbackChannel) {
@@ -1782,6 +1802,7 @@ function renderPostCard(post, {
onRepost,
onShare,
onEdit,
isActive = () => true,
}) {
const versionsTotal = Number(post?.versionsTotal || 1);
@@ -1792,7 +1813,7 @@ function renderPostCard(post, {
const authorTile = document.createElement('button');
authorTile.type = 'button';
authorTile.className = 'channel-message-author-tile';
authorTile.className = 'ui-button channel-message-author-tile';
const avatar = createMessageAvatar(post.authorLogin);
@@ -1821,7 +1842,7 @@ function renderPostCard(post, {
if (versionsTotal > 1) {
const editedMarker = document.createElement('button');
editedMarker.type = 'button';
editedMarker.className = 'message-edited-marker';
editedMarker.className = 'ui-button message-edited-marker';
editedMarker.textContent = `изменено ${Math.max(1, versionsTotal - 1)}`;
editedMarker.title = 'Открыть историю редактирования';
editedMarker.addEventListener('click', (event) => {
@@ -1841,7 +1862,7 @@ function renderPostCard(post, {
if (typeMeta) {
const typeButton = document.createElement('button');
typeButton.type = 'button';
typeButton.className = 'channel-message-type-button';
typeButton.className = 'ui-button channel-message-type-button';
typeButton.textContent = typeMeta.label;
if (typeMeta.actionable && typeof onStatusAction === 'function') {
typeButton.addEventListener('click', (event) => {
@@ -1875,7 +1896,7 @@ function renderPostCard(post, {
card.classList.add('channel-message-card--deleted-compact');
const deleted = document.createElement('button');
deleted.type = 'button';
deleted.className = 'deleted-message-pill';
deleted.className = 'ui-button deleted-message-pill';
deleted.textContent = `Удалённое сообщение от ${post.authorLogin}`;
deleted.title = 'Открыть историю изменений';
deleted.addEventListener('click', (event) => {
@@ -1946,7 +1967,7 @@ function renderPostCard(post, {
const likeButton = document.createElement('button');
likeButton.type = 'button';
likeButton.className = 'channel-action-item channel-action-like';
likeButton.className = 'ui-button channel-action-item channel-action-like';
const isLiked = post.reactionState === 'liked';
if (isLiked) likeButton.classList.add('is-liked');
likeButton.innerHTML = `
@@ -1972,7 +1993,7 @@ function renderPostCard(post, {
const replyButton = document.createElement('button');
replyButton.type = 'button';
replyButton.className = 'channel-action-item channel-action-reply';
replyButton.className = 'ui-button channel-action-item channel-action-reply';
replyButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">💬</span>
<span class="channel-action-label">Ответить</span>
@@ -1985,6 +2006,7 @@ function renderPostCard(post, {
openReplyModal({
navigate,
onSubmit: async (text) => onReply(post.messageRef, text),
isActive,
});
});
// Rating/opinion action is intentionally hidden from UI for now.
@@ -1994,7 +2016,7 @@ function renderPostCard(post, {
const shareButton = document.createElement('button');
shareButton.type = 'button';
shareButton.className = 'channel-action-item channel-action-share';
shareButton.className = 'ui-button channel-action-item channel-action-share';
shareButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↗</span>
<span class="channel-action-label">Отправить</span>
@@ -2011,7 +2033,7 @@ function renderPostCard(post, {
if (post.msgSubType === MSG_SUBTYPE_TEXT_REPOST && post.targetRef?.blockchainName && Number.isFinite(post.targetRef?.blockNumber) && post.targetRef?.blockHash) {
const originalBtn = document.createElement('button');
originalBtn.type = 'button';
originalBtn.className = 'channel-action-item';
originalBtn.className = 'ui-button channel-action-item';
originalBtn.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">↪</span>
<span class="channel-action-label">Оригинал</span>
@@ -2033,7 +2055,7 @@ function renderPostCard(post, {
}
const detailsButton = document.createElement('button');
detailsButton.type = 'button';
detailsButton.className = 'channel-action-item';
detailsButton.className = 'ui-button channel-action-item';
detailsButton.innerHTML = `
<span class="channel-action-icon" aria-hidden="true">⛓</span>
<span class="channel-action-label">Данные блокчейна</span>
@@ -2049,13 +2071,13 @@ function renderPostCard(post, {
raw: post.rawMessage,
localNumber: post.localNumber,
msgSubType: post.msgSubType,
}));
}), { isActive });
});
actions.append(detailsButton);
if (post.isOwnMessage) {
const editButton = document.createElement('button');
editButton.type = 'button';
editButton.className = 'channel-action-item';
editButton.className = 'ui-button channel-action-item';
editButton.setAttribute('aria-label', 'Редактировать');
editButton.title = 'Редактировать';
editButton.innerHTML = `
@@ -2155,6 +2177,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
onRepost: handlers.onRepost,
onShare: handlers.onShare,
onEdit: handlers.onEdit,
isActive: handlers.isActive,
});
const key = messageRefKey(item.post.messageRef);
if (key) {
@@ -2192,10 +2215,10 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
}
const hasPendingScrollTarget = pendingScrollByRoute.has(routeKey);
applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
if (!hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary) {
window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40);
}
const pendingScrollTimer = applyPendingScroll(screen, routeKey, channelData.isOwnChannel || channelData.isDiary || unreadCount === 0);
const unreadScrollTimer = !hasPendingScrollTarget && unreadCount > 0 && !channelData.isOwnChannel && !channelData.isDiary
? window.setTimeout(() => scrollChannelToUnreadLine(screen, unreadCount, false), 40)
: 0;
const tracker = createChannelReadTracker({
screen,
@@ -2209,7 +2232,11 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
initialSeenCount: readCount,
});
return tracker.cleanup;
return () => {
if (pendingScrollTimer) window.clearTimeout(pendingScrollTimer);
if (unreadScrollTimer) window.clearTimeout(unreadScrollTimer);
tracker.cleanup();
};
}
function renderSkeleton(screen) {
@@ -2227,14 +2254,22 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--channel';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
let disposed = false;
let refreshSeq = 0;
let refresh = () => {};
let cleanupSeenTracking = null;
const statusBox = document.createElement('div');
statusBox.className = 'card status-line is-unavailable channels-status';
statusBox.style.display = 'none';
const ensureActive = () => {
if (disposed) throw new Error('Экран канала уже закрыт.');
};
const showStatus = (message) => {
if (disposed) return;
if (!message) {
statusBox.style.display = 'none';
statusBox.textContent = '';
@@ -2244,36 +2279,79 @@ export function render({ navigate, route, chrome }) {
statusBox.style.display = '';
};
let activeChannelData = null;
const channelHeaderButton = document.createElement('button');
channelHeaderButton.type = 'button';
channelHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
const header = renderHeader({
centerNode: channelHeaderButton,
leftAction: { label: '<', onClick: () => navigate('channels-list') },
rightActions: [
const header = createTopBar({
center: channelHeaderButton,
back: { onClick: () => navigate('channels-list') },
className: 'channel-view-topbar',
actions: [
{ label: 'Оглавление', className: 'channel-header-entrypoint-btn', onClick: () => {} },
{ label: '⋯', className: 'channel-header-more-btn', onClick: () => {} },
{
label: '⋯',
title: 'Действия канала',
ariaLabel: 'Открыть меню канала',
className: 'channel-header-more-btn',
menu: {
minWidth: 210,
items: () => {
const apiData = activeChannelData;
if (!apiData) return [];
const aboutRoute = makeShineChannelAboutRoute({
ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '',
channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? apiData?.channel?.channelRoot?.blockNumber ?? '',
channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? apiData?.channel?.channelRoot?.blockHash ?? '',
});
const items = [
{ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } },
];
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
items.push({
label: 'Отписаться от канала',
danger: true,
action: async () => {
try {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: apiData.selector.ownerBlockchainName,
targetBlockNumber: apiData.selector.channelRootBlockNumber,
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: true,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
showToast('Вы отписались от канала');
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось отписаться от канала.'));
}
},
});
}
return items;
},
},
},
],
});
header.classList.add('channel-view-topbar');
const channelEntrypointButton = header.querySelector('.header-actions .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.header-actions .channel-header-more-btn');
const channelEntrypointButton = header.querySelector('.topbar__right .channel-header-entrypoint-btn');
const channelMoreButton = header.querySelector('.topbar__right .channel-header-more-btn');
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
}
chrome?.setTopbar(header);
const rerender = () => {
const current = document.querySelector('section.channels-screen--channel');
if (!current) return;
const next = render({ navigate, route });
current.cleanup?.();
current.replaceWith(next);
};
let activeSelector = null;
const requireSigningSession = () => {
@@ -2305,12 +2383,14 @@ export function render({ navigate, route, chrome }) {
} else {
await authService.addBlockLike({ login, storagePwd, message: messageRef });
}
if (disposed) return;
setMessageReactionState(messageRef, nextReaction);
softHaptic(10);
rerender();
void refresh();
} catch (error) {
if (disposed) return;
setMessageReactionState(messageRef, previousReaction || 'unliked');
rerender();
void refresh();
throw error;
} finally {
pendingReactionActions.delete(actionKey);
@@ -2320,25 +2400,27 @@ export function render({ navigate, route, chrome }) {
const onReply = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockReply({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Ответ отправлен');
rerender();
void refresh();
};
const onRating = async (messageRef, text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockRating({ login, storagePwd, message: messageRef, text });
ensureActive();
const scrollTarget = messageRefKey(messageRef);
if (scrollTarget) pendingScrollByRoute.set(routeKey, scrollTarget);
softHaptic(15);
showToast('Оценка отправлена');
rerender();
void refresh();
};
const onStatusAction = async (post) => {
@@ -2348,10 +2430,12 @@ export function render({ navigate, route, chrome }) {
openStatusActionMenuModal({
targetLabel: typeMeta.label,
options,
isActive: () => !disposed,
onSelect: async (option) => {
openStatusActionCommentModal({
title: option.modalTitle,
submitLabel: option.label,
isActive: () => !disposed,
onSubmit: async (text) => {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockStatusAction({
@@ -2361,9 +2445,10 @@ export function render({ navigate, route, chrome }) {
text,
statusSubType: option.subType,
});
ensureActive();
softHaptic(14);
showToast(`${option.label} сохранено`);
rerender();
void refresh();
},
});
},
@@ -2403,10 +2488,12 @@ export function render({ navigate, route, chrome }) {
const onRepost = async (messageRef) => {
const { login, storagePwd } = requireSigningSession();
const channels = await loadOwnedChannelsForRepost(login);
if (disposed) return;
if (!channels.length) throw new Error('У вас пока нет каналов для репоста.');
openRepostModal({
navigate,
channels,
isActive: () => !disposed,
onSubmit: async ({ channel, text }) => {
await authService.addBlockRepost({
login,
@@ -2415,9 +2502,10 @@ export function render({ navigate, route, chrome }) {
message: messageRef,
text,
});
ensureActive();
if (isSameChannelSelector(channel, activeSelector)) {
pendingScrollByRoute.set(routeKey, '__LAST__');
rerender();
void refresh();
}
softHaptic(12);
showToast('Репост опубликован');
@@ -2434,6 +2522,7 @@ export function render({ navigate, route, chrome }) {
text: 'Тред из канала SHiNE',
url: buildAbsoluteRouteUrl(routeToShare),
});
if (disposed) return;
if (result === 'copied') showToast('Ссылка скопирована');
if (result === 'shared') showToast('Ссылка передана');
if (result === 'shared' || result === 'copied') softHaptic(10);
@@ -2455,11 +2544,12 @@ export function render({ navigate, route, chrome }) {
text: bodyText,
msgSubType,
});
ensureActive();
pendingScrollByRoute.set(routeKey, '__LAST__');
softHaptic(15);
showToast('Сообщение отправлено');
rerender();
void refresh();
};
const onEditPost = async (messageRef, text) => {
@@ -2476,9 +2566,10 @@ export function render({ navigate, route, chrome }) {
isChannelPost: !isDiaryEdit,
channel: isDiaryEdit ? null : activeSelector,
});
ensureActive();
softHaptic(12);
showToast('Сообщение обновлено');
rerender();
void refresh();
};
const onEditChannelMeta = async ({ title, description, avatar }) => {
@@ -2494,21 +2585,65 @@ export function render({ navigate, route, chrome }) {
description,
avatar,
});
ensureActive();
if (avatar?.ar) markArweaveAttachmentPlaced(login, avatar);
softHaptic(12);
showToast('Профиль канала обновлён');
rerender();
void refresh();
};
screen.append(statusBox);
const skeleton = renderSkeleton(screen);
const clearContent = () => {
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
cleanupSeenTracking = null;
Array.from(screen.children).forEach((child) => {
if (child !== statusBox) child.remove();
});
};
let cleanupSeenTracking = null;
const clearOwnedModal = () => {
const modalRoot = document.getElementById('modal-root');
if (!modalRoot) return;
const ownedSelector = [
'#about-channel-modal',
'#blockchain-details-modal',
'#channel-entrypoint-history-modal',
'#channel-entrypoint-menu-modal',
'#channel-message-modal',
'#channel-status-action-modal',
'#channel-status-menu-modal',
'#edit-channel-modal',
'#edit-message-modal',
'#message-history-modal',
'#reply-modal',
'#repost-modal',
].join(',');
if (modalRoot.querySelector(ownedSelector)) modalRoot.innerHTML = '';
};
refresh = async () => {
if (disposed) return;
const seq = ++refreshSeq;
clearContent();
activeChannelData = null;
activeSelector = null;
showStatus('');
channelHeaderButton.textContent = 'Канал: ...';
channelHeaderButton.disabled = true;
channelHeaderButton.onclick = null;
if (channelMoreButton) channelMoreButton.disabled = true;
if (channelEntrypointButton) {
channelEntrypointButton.disabled = true;
channelEntrypointButton.hidden = true;
channelEntrypointButton.onclick = null;
}
const skeleton = renderSkeleton(screen);
(async () => {
try {
const apiData = await loadFromApi(route, channelId);
if (disposed || seq !== refreshSeq) return;
activeSelector = apiData?.selector || null;
const titleLabel = apiData?.channel?.displayTitle || apiData?.channel?.name || 'channel';
const entrypointPosts = getEntrypointPosts(apiData?.posts);
@@ -2536,35 +2671,9 @@ export function render({ navigate, route, chrome }) {
if (aboutRoute) navigate(aboutRoute);
};
}
activeChannelData = apiData;
if (channelMoreButton) {
channelMoreButton.disabled = false;
channelMoreButton.onclick = (event) => {
event.stopPropagation();
header.querySelector('.channel-header-more-menu')?.remove();
const menu = document.createElement('div');
menu.className = 'channel-header-more-menu';
const about = document.createElement('button'); about.type='button'; about.textContent='О канале';
about.onclick = () => {
const aboutRoute = makeShineChannelAboutRoute({ ownerBlockchainName: apiData?.channel?.ownerBlockchainName || apiData?.selector?.ownerBlockchainName || '', channelRootBlockNumber: apiData?.selector?.channelRootBlockNumber ?? '', channelRootBlockHash: apiData?.selector?.channelRootBlockHash ?? '' });
menu.remove(); if (aboutRoute) navigate(aboutRoute);
};
menu.append(about);
if (apiData?.isSubscribed && !apiData?.isOwnChannel) {
const unfollow = document.createElement('button'); unfollow.type='button'; unfollow.className='is-danger'; unfollow.textContent='Отписаться от канала';
unfollow.onclick = async () => {
menu.remove();
try {
const { login, storagePwd } = requireSigningSession();
await authService.addBlockFollowChannel({ login, storagePwd, targetBlockchainName: apiData.selector.ownerBlockchainName, targetBlockNumber: apiData.selector.channelRootBlockNumber, targetBlockHashHex: apiData.selector.channelRootBlockHash, unfollow: true });
const feed = await authService.listSubscriptionsFeed(login, 200); setChannelsFeed(feed, state.channelsIndex); showToast('Вы отписались от канала'); rerender();
} catch (error) { showStatus(toUserMessage(error, 'Не удалось отписаться от канала.')); }
};
menu.append(unfollow);
}
header.append(menu);
const close = (e) => { if (!menu.contains(e.target) && e.target !== channelMoreButton) { menu.remove(); document.removeEventListener('click', close, true); } };
setTimeout(() => document.addEventListener('click', close, true), 0);
};
}
if (channelEntrypointButton) {
const canShowEntrypointButton = !apiData?.isDiary && entrypointPosts.length > 0;
@@ -2579,10 +2688,12 @@ export function render({ navigate, route, chrome }) {
}
skeleton.remove();
cleanupSeenTracking = renderBody(screen, navigate, routeKey, apiData, {
isActive: () => !disposed,
onAddMessage: () => {
openAddMessageModal({
channelName: apiData?.channel?.name || '',
navigate,
isActive: () => !disposed,
onSubmit: async ({ text: bodyText, msgSubType }) => {
try {
await onAddPost(bodyText, msgSubType);
@@ -2672,31 +2783,41 @@ export function render({ navigate, route, chrome }) {
targetBlockHashHex: apiData.selector.channelRootBlockHash,
unfollow: false,
});
if (disposed) return;
const feed = await authService.listSubscriptionsFeed(login, 200);
if (disposed) return;
setChannelsFeed(feed, state.channelsIndex);
softHaptic(15);
showToast('Подписка на канал выполнена');
rerender();
void refresh();
} catch (error) {
showStatus(toUserMessage(error, 'Не удалось подписаться на канал.'));
}
},
});
} catch (error) {
if (disposed || seq !== refreshSeq) return;
skeleton.remove();
if (isChannelsDemoMode()) {
renderDemoFallback(screen, navigate, error);
return;
}
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), rerender);
renderLoadError(screen, navigate, toUserMessage(error, 'Не удалось загрузить канал.'), () => {
void refresh();
});
}
})();
screen.cleanup = () => {
appScreen?.classList.remove('channels-scroll-clean');
if (typeof cleanupSeenTracking === 'function') cleanupSeenTracking();
};
screen.refresh = refresh;
screen.cleanup = () => {
if (disposed) return;
disposed = true;
refreshSeq += 1;
clearContent();
clearOwnedModal();
};
void refresh();
return screen;
}
+26 -293
View File
@@ -16,13 +16,12 @@ import { makeShineChannelShortRoute } from '../services/shine-routes.js';
import { renderAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
export const pageMeta = { id: 'channels-list', title: 'Каналы' };
export const pageMeta = { id: 'channels-list', title: 'Каналы', shellMode: { scrollbar: 'hidden' } };
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
const MENU_OVERLAY_ID = 'channels-context-menu-overlay';
const TOP_MENU_OVERLAY_ID = 'channels-top-menu-overlay';
const CHANNEL_TYPE_STORIES = 0;
const CHANNEL_TYPE_PERSONAL = 100;
const DIARY_CHANNEL_NAME = 'diary';
@@ -311,7 +310,7 @@ function renderSuggestions(container, values, onPick) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -581,7 +580,7 @@ function openChannelFinderModal({ navigate }) {
values.forEach((value) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-search-item';
btn.className = 'ui-button channel-search-item';
btn.textContent = value.label;
btn.addEventListener('click', () => onPick(value));
container.append(btn);
@@ -998,252 +997,6 @@ function renderDemoFallback(container, navigate, error, onRetry, { localOnly = f
container.append(list);
}
function closeChannelMenu(listState, clearOpenMenuId = true) {
if (typeof listState.menuCleanup === 'function') {
listState.menuCleanup();
}
listState.menuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
if (clearOpenMenuId) {
listState.openMenuId = null;
}
}
function closeTopChannelsMenu(listState) {
if (typeof listState.topMenuCleanup === 'function') {
listState.topMenuCleanup();
}
listState.topMenuCleanup = null;
const root = document.getElementById('modal-root');
if (root) {
const overlay = root.querySelector(`#${TOP_MENU_OVERLAY_ID}`);
if (overlay) overlay.remove();
}
}
function openTopChannelsMenu({
listState,
anchorEl,
navigate,
onFindChannel,
}) {
closeTopChannelsMenu(listState);
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: anchorEl } }));
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(280, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 250;
const titleAnchor = document.querySelector('.channels-filter-title');
const titleRect = titleAnchor?.getBoundingClientRect?.();
let top = (titleRect?.bottom || rect.bottom) + 7;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = TOP_MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const items = [
{ label: 'Найти канал', icon: 'search', action: () => onFindChannel?.() },
{ label: 'Новый канал', icon: 'add', action: () => navigate('add-channel-view') },
];
items.forEach((item) => {
if (item.divider) {
const divider = document.createElement('div');
divider.className = 'channel-menu-divider';
divider.style.height = '1px';
divider.style.background = 'rgba(255,255,255,0.08)';
divider.style.margin = '6px 0';
menu.append(divider);
return;
}
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'channel-menu-item';
btn.innerHTML = `${channelMenuIcon(item.icon)}<span>${item.label}</span>`;
btn.addEventListener('click', () => {
closeTopChannelsMenu(listState);
item.action?.();
});
menu.append(btn);
});
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) closeTopChannelsMenu(listState);
};
const onPeerDropdownOpen = (event) => {
if (event?.detail?.owner === anchorEl) return;
closeTopChannelsMenu(listState);
};
const onWindowResize = () => closeTopChannelsMenu(listState);
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
listState.topMenuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
};
}
function openChannelMenu({ listState, channel, anchorEl, refreshFeed, rerenderList }) {
closeChannelMenu(listState, false);
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const rect = anchorEl.getBoundingClientRect();
const menuWidth = Math.min(250, Math.max(220, window.innerWidth - 28));
let left = rect.right - menuWidth;
left = Math.max(12, Math.min(left, window.innerWidth - menuWidth - 12));
const estimatedHeight = 210;
let top = rect.bottom + 8;
if (top + estimatedHeight > window.innerHeight - 10) {
top = Math.max(12, rect.top - estimatedHeight - 8);
}
const overlay = document.createElement('div');
overlay.id = MENU_OVERLAY_ID;
overlay.className = 'channels-menu-overlay';
const menu = document.createElement('div');
menu.className = 'channel-menu-wrap channel-menu-wrap--portal';
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(top)}px`;
menu.style.width = `${Math.round(menuWidth)}px`;
const canToggleSubscription = !channel.isOwnChannel;
const actionBtn = document.createElement('button');
actionBtn.type = 'button';
actionBtn.className = `channel-menu-item ${channel.isSubscribed ? 'destructive' : ''}`.trim();
const actionLabel = document.createElement('span');
actionBtn.append(document.createRange().createContextualFragment(channelMenuIcon('subscribe')), actionLabel);
if (canToggleSubscription) {
actionLabel.textContent = channel.pending
? 'Выполняется...'
: channel.isSubscribed
? 'Отписаться'
: 'Подписаться';
actionBtn.disabled = !!channel.pending;
actionBtn.addEventListener('click', async (event) => {
event.stopPropagation();
if (channel.pending) return;
const login = state.session.login;
const storagePwd = state.session.storagePwdInMemory;
if (!login || !storagePwd) {
showToast('Сессия недействительна. Выполните вход заново.', { kind: 'error' });
return;
}
channel.pending = true;
actionBtn.disabled = true;
actionLabel.textContent = 'Выполняется...';
const nextSubscribed = !channel.isSubscribed;
try {
await authService.addBlockFollowChannel({
login,
storagePwd,
targetBlockchainName: channel.ownerBlockchainName,
targetBlockNumber: channel.channelRootBlockNumber,
targetBlockHashHex: channel.channelRootBlockHash,
unfollow: !nextSubscribed,
});
channel.isSubscribed = nextSubscribed;
channel.pending = false;
softHaptic(15);
showToast(nextSubscribed ? 'Подписка на канал включена' : 'Подписка на канал отключена');
closeChannelMenu(listState);
await refreshFeed();
} catch (error) {
channel.pending = false;
actionBtn.disabled = false;
actionLabel.textContent = channel.isSubscribed ? 'Отписаться' : 'Подписаться';
showToast(toUserMessage(error, 'Не удалось изменить подписку.'), { kind: 'error' });
rerenderList();
}
});
} else {
actionLabel.textContent = 'Собственный канал';
actionBtn.disabled = true;
}
const toggleWrap = document.createElement('div');
toggleWrap.className = 'channel-menu-toggle';
const toggleLabel = document.createElement('span');
toggleLabel.className = 'channel-menu-toggle-label';
toggleLabel.innerHTML = `${channelMenuIcon('notifications')}<span>Уведомления</span>`;
const toggleBtn = document.createElement('button');
toggleBtn.type = 'button';
toggleBtn.className = `channel-toggle-btn ${channel.notificationsEnabled ? 'is-on' : ''}`.trim();
toggleBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(toggleBtn);
channel.notificationsEnabled = !channel.notificationsEnabled;
const next = { ...listState.notificationsState, [channel.id]: channel.notificationsEnabled };
listState.notificationsState = next;
writeChannelNotificationsState(next);
toggleBtn.classList.toggle('is-on', channel.notificationsEnabled);
softHaptic(10);
});
toggleWrap.append(toggleLabel, toggleBtn);
menu.append(actionBtn, toggleWrap);
overlay.append(menu);
root.append(overlay);
const onOverlayClick = (event) => {
if (event.target === overlay) {
closeChannelMenu(listState);
rerenderList();
}
};
const onWindowResize = () => {
closeChannelMenu(listState);
rerenderList();
};
overlay.addEventListener('click', onOverlayClick);
window.addEventListener('resize', onWindowResize);
listState.menuCleanup = () => {
overlay.removeEventListener('click', onOverlayClick);
window.removeEventListener('resize', onWindowResize);
};
}
function renderChannelMain(channel) {
const main = document.createElement('div');
main.className = 'channel-row-main';
@@ -1350,7 +1103,6 @@ function updateBottomCta({ button }) {
}
async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
closeChannelMenu(listState);
renderSkeletonList(contentEl, 5);
if (!state.session.isAuthorized) {
@@ -1411,39 +1163,29 @@ async function loadFeedAndRender({ screen, listState, contentEl, navigate }) {
export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack channels-screen channels-screen--list';
const appScreen = document.getElementById('app-screen');
appScreen?.classList.add('channels-scroll-clean');
const createSuccessFlash = pullCreateSuccessFlash();
const notificationsState = readChannelNotificationsState();
const isGuest = !state.session.isAuthorized;
const listState = {
openMenuId: null,
topMenuCleanup: null,
notificationsState,
revealedCounters: new Set(),
channels: [],
menuCleanup: null,
viewMode: normalizeChannelsViewMode(route),
};
const contentEl = document.createElement('div');
contentEl.className = 'channels-list-content';
const topBarEl = document.createElement('div');
topBarEl.className = 'channels-top-bar';
const topBarLeft = document.createElement('div');
topBarLeft.className = 'channels-top-left';
const topTitle = document.createElement('button');
topTitle.type = 'button';
topTitle.className = 'channels-top-title channels-filter-title';
const channelsFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: topTitle,
align: 'left',
placement: 'bottom-start',
leftShift: 72,
minWidth: 220,
items: [
@@ -1453,28 +1195,25 @@ export function render({ navigate, route, chrome }) {
],
});
const topBarRight = document.createElement('div');
topBarRight.className = 'channels-top-right';
const topMenuBtn = document.createElement('button');
topMenuBtn.type = 'button';
topMenuBtn.className = 'icon-btn channels-top-more-btn';
topMenuBtn.setAttribute('aria-label', 'Ещё действия');
topMenuBtn.title = 'Ещё действия';
topMenuBtn.append(createOverflowDots());
topMenuBtn.addEventListener('click', (event) => {
event.stopPropagation();
animatePress(topMenuBtn);
openTopChannelsMenu({
listState,
anchorEl: topMenuBtn,
navigate,
onFindChannel: () => openChannelFinderModal({ navigate }),
});
const topBarEl = createTopBar({
center: topTitle,
actions: [
{
iconNode: createOverflowDots(),
title: 'Ещё действия',
ariaLabel: 'Ещё действия',
className: 'channels-top-more-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти канал', iconHtml: channelMenuIcon('search'), action: () => openChannelFinderModal({ navigate }) },
{ label: 'Новый канал', iconHtml: channelMenuIcon('add'), action: () => navigate('add-channel-view') },
],
},
},
],
});
topBarRight.append(topMenuBtn);
topBarEl.append(topBarLeft, topTitle, topBarRight);
const topMenuBtn = topBarEl.querySelector('.channels-top-more-btn');
const bottomCta = document.createElement('button');
bottomCta.type = 'button';
@@ -1483,9 +1222,7 @@ export function render({ navigate, route, chrome }) {
const rerenderList = () => {
listState.viewMode = normalizeChannelsViewMode({ params: route?.params || {} });
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
renderListContent({
screen,
container: contentEl,
@@ -1496,7 +1233,6 @@ export function render({ navigate, route, chrome }) {
topTitle.textContent = channelsViewTitle(listState.viewMode);
topMenuBtn.style.display = '';
if (topTitle.parentElement !== topBarEl) topBarEl.insertBefore(topTitle, topBarRight);
updateBottomCta({ button: bottomCta });
};
@@ -1516,10 +1252,7 @@ export function render({ navigate, route, chrome }) {
loadFeedAndRender({ screen, listState, contentEl, navigate });
screen.cleanup = () => {
closeChannelMenu(listState);
closeTopChannelsMenu(listState);
channelsFilterMenu.destroy();
appScreen?.classList.remove('channels-scroll-clean');
channelsFilterMenu.destroy();
};
return screen;
+100 -208
View File
@@ -1,4 +1,5 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { directMessages } from '../mock-data.js';
@@ -32,7 +33,18 @@ import { buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } f
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-routes.js';
export const pageMeta = { id: 'chat-view', title: 'Чат' };
export const pageMeta = {
id: 'chat-view',
title: 'Чат',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'composer',
contentUnderTopbar: true,
contentUnderBottom: true,
scrollContainer: 'nested',
},
};
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
function menuIconSvg(name) {
@@ -49,58 +61,6 @@ function menuIconSvg(name) {
return `<svg class="dm-menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || ''}</svg>`;
}
function openUserIdentityMenu({ anchorEl, login, navigate }) {
const root = document.getElementById('modal-root');
if (!root || !anchorEl) return;
const cleanLogin = String(login || '').trim();
root.innerHTML = `
<div class="dm-floating-menu-layer dm-user-menu-layer" id="chat-user-menu-layer">
<div class="dm-head-menu dm-head-menu--portal dm-user-identity-menu" role="menu">
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="connections">
<img class="dm-menu-image-icon" src="/assets/SHiNE_connections_blue.svg" alt="" aria-hidden="true" />
<span>Показать связи</span>
</button>
<button type="button" class="dm-head-menu-item" role="menuitem" data-user-action="profile">
<img class="dm-menu-image-icon" src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true" />
<span>Показать профиль</span>
</button>
</div>
</div>
`;
const layer = root.querySelector('#chat-user-menu-layer');
const menu = root.querySelector('.dm-user-identity-menu');
const close = () => {
document.removeEventListener('keydown', onKeydown);
root.innerHTML = '';
};
const onKeydown = (event) => {
if (event.key === 'Escape') close();
};
const rect = anchorEl.getBoundingClientRect();
window.requestAnimationFrame(() => {
if (!menu) return;
const width = menu.offsetWidth || 190;
const left = Math.max(10, Math.min(window.innerWidth - width - 10, rect.left));
menu.style.left = `${Math.round(left)}px`;
menu.style.top = `${Math.round(rect.bottom + 7)}px`;
});
layer?.addEventListener('pointerdown', (event) => {
if (event.target === layer) close();
});
document.addEventListener('keydown', onKeydown);
root.querySelector('[data-user-action="connections"]')?.addEventListener('click', () => {
close();
navigate(makeProfileLinksRoute(cleanLogin));
});
root.querySelector('[data-user-action="profile"]')?.addEventListener('click', () => {
close();
navigate(makeProfileRoute(cleanLogin));
});
}
function normalizeChatRelationType(value) {
const clean = String(value || '').trim().toLowerCase();
if (clean === 'close_friend' || clean === 'friend' || clean === 'contact') return clean;
@@ -178,15 +138,30 @@ function createChatHeaderParts(login, navigate) {
};
renderPeer();
identityButton.addEventListener('click', (event) => {
if (!cleanLogin || cleanLogin === 'unknown') return;
openUserIdentityMenu({ anchorEl: event.currentTarget, login: cleanLogin, navigate });
const identityMenu = createDropdownMenu({
transparent: true,
anchorEl: identityButton,
placement: 'bottom-start',
minWidth: 210,
items: [
{
label: 'Показать связи',
iconSrc: '/assets/SHiNE_connections_blue.svg',
action: () => navigate(makeProfileLinksRoute(cleanLogin)),
},
{
label: 'Показать профиль',
iconSrc: '/assets/profile-icon-profile.svg',
action: () => navigate(makeProfileRoute(cleanLogin)),
},
],
});
return {
centerNode: identityButton,
center: identityButton,
updatePeer,
getPeer: () => ({ ...currentPeer }),
cleanup: () => identityMenu.destroy(),
};
}
@@ -414,91 +389,6 @@ function openMessageActionsMenu({
});
}
function openChatActionsMenu({
anchorX = 0,
anchorY = 0,
showAddContact = false,
onCall,
onVideoCall,
onAddContact,
onClearHistory,
onDeleteChat,
}) {
const root = document.getElementById('modal-root');
if (!root) return;
const menuId = `chat-header-actions-menu-${Date.now()}`;
root.innerHTML = `
<div class="dm-floating-menu-layer" id="chat-header-actions-layer">
<div class="modal-card stack dm-message-actions-menu dm-message-actions-popover" id="${menuId}">
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-call">${menuIconSvg('call')}<span>Звонок</span></button>
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-video-call">${menuIconSvg('video')}<span>Видеозвонок</span></button>
${showAddContact ? `<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-add-contact"><span class="dm-menu-icon" aria-hidden="true"></span><span>Добавить в контакты</span></button>` : ''}
<button class="secondary-btn dm-message-action-btn" type="button" id="chat-menu-clear-history">${menuIconSvg('clear')}<span>Очистить историю</span></button>
<button class="secondary-btn dm-message-action-btn dm-message-action-btn--danger" type="button" id="chat-menu-delete-chat">${menuIconSvg('delete')}<span>Удалить чат</span></button>
</div>
</div>
`;
const menu = root.querySelector(`#${menuId}`);
if (!menu) return;
const close = () => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true);
window.removeEventListener('resize', close);
window.removeEventListener('scroll', close, true);
root.innerHTML = '';
};
const onDocumentPointerDown = (event) => {
if (menu.contains(event.target)) return;
close();
};
document.addEventListener('pointerdown', onDocumentPointerDown, true);
window.addEventListener('resize', close);
window.addEventListener('scroll', close, true);
window.requestAnimationFrame(() => {
const menuRect = menu.getBoundingClientRect();
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const left = Math.min(
Math.max(12, Number(anchorX || 0) - menuRect.width + 8),
Math.max(12, viewportWidth - menuRect.width - 12)
);
const top = Math.min(
Math.max(12, Number(anchorY || 0) + 10),
Math.max(12, viewportHeight - menuRect.height - 12)
);
menu.style.left = `${left}px`;
menu.style.top = `${top}px`;
menu.style.transformOrigin = `${Math.round(Number(anchorX || left) - left)}px top`;
menu.classList.add('is-visible');
});
root.querySelector('#chat-menu-call')?.addEventListener('click', async () => {
close();
if (typeof onCall === 'function') await onCall();
});
root.querySelector('#chat-menu-video-call')?.addEventListener('click', async () => {
close();
if (typeof onVideoCall === 'function') await onVideoCall();
});
root.querySelector('#chat-menu-add-contact')?.addEventListener('click', async () => {
close();
if (typeof onAddContact === 'function') await onAddContact();
});
root.querySelector('#chat-menu-clear-history')?.addEventListener('click', async () => {
close();
if (typeof onClearHistory === 'function') await onClearHistory();
});
root.querySelector('#chat-menu-delete-chat')?.addEventListener('click', async () => {
close();
if (typeof onDeleteChat === 'function') await onDeleteChat();
});
}
function showTtsMissingConfigDialog() {
const root = document.getElementById('modal-root');
if (!root) return;
@@ -853,7 +743,7 @@ function renderLog(
const replyParsed = parseDmTechBlocks(String(replyTarget?.text || ''));
const replyBox = document.createElement('button');
replyBox.type = 'button';
replyBox.className = 'bubble-reply-preview';
replyBox.className = 'ui-button bubble-reply-preview';
const replyAuthor = document.createElement('div');
replyAuthor.className = 'bubble-reply-preview-author';
@@ -998,7 +888,6 @@ async function mergeDirectMessagesPage(chatId, payloadMessages) {
}
export function render({ navigate, route, chrome }) {
document.body.classList.add('chat-topbar-overlay');
const routeChatId = route.params.chatId || 'u1';
const chatId = normalizeDmChatId(routeChatId) || 'u1';
const contact = directMessages.find((d) => normalizeDmChatId(d.id) === chatId) || {
@@ -1140,10 +1029,10 @@ export function render({ navigate, route, chrome }) {
log.className = 'messages-log dm-messages-log';
const chatHeaderParts = createChatHeaderParts(chatId, navigate);
const chatHeader = renderHeader({
centerNode: chatHeaderParts.centerNode,
leftAction: { label: '←', onClick: () => navigate('messages-list') },
rightActions: [
const chatHeader = createTopBar({
center: chatHeaderParts.center,
back: { label: '←', onClick: () => navigate('messages-list') },
actions: [
{
title: 'Позвонить',
ariaLabel: 'Позвонить',
@@ -1156,22 +1045,28 @@ export function render({ navigate, route, chrome }) {
title: 'Действия чата',
ariaLabel: 'Открыть меню действий чата',
className: 'chat-header-icon-btn chat-header-menu-btn',
onClick: (event) => {
openChatActionsMenu({
anchorX: Number(event?.currentTarget?.getBoundingClientRect?.().right || event?.clientX || 0),
anchorY: Number(event?.currentTarget?.getBoundingClientRect?.().bottom || event?.clientY || 0),
showAddContact: normalizeChatRelationType(peerRelationType) === 'none',
onCall: () => handleStartCall('audio'),
onVideoCall: () => handleStartCall('video'),
onAddContact: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
menu: {
minWidth: 230,
items: () => [
{ label: 'Звонок', iconHtml: menuIconSvg('call'), action: () => handleStartCall('audio') },
{ label: 'Видеозвонок', iconHtml: menuIconSvg('video'), action: () => handleStartCall('video') },
normalizeChatRelationType(peerRelationType) === 'none'
? {
label: 'Добавить в контакты',
iconHtml: '<span aria-hidden="true"></span>',
action: async () => {
try {
await addPeerToContacts();
} catch (error) {
showToast(`Не удалось добавить в контакты: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
}
},
onClearHistory: async () => {
openChatConfirmModal({
: null,
{
label: 'Очистить историю',
iconHtml: menuIconSvg('clear'),
action: () => openChatConfirmModal({
title: 'Очистить историю?',
text: `Добавить техническое сообщение очистки истории переписки с ${contact.name}?`,
confirmLabel: 'Очистить',
@@ -1184,55 +1079,54 @@ export function render({ navigate, route, chrome }) {
showToast(`Не удалось очистить историю: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
}),
},
onDeleteChat: async () => {
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
openDeleteChatConfirmModal({
contactName: contact.name,
relationType: relationBeforeDelete,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) {
await clearConversationHistory();
{
label: 'Удалить чат',
iconHtml: menuIconSvg('delete'),
danger: true,
action: () => {
const relationBeforeDelete = normalizeChatRelationType(peerRelationType);
openDeleteChatConfirmModal({
contactName: contact.name,
relationType: relationBeforeDelete,
onConfirm: async ({ deleteHistory }) => {
try {
if (deleteHistory) await clearConversationHistory();
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
? ['close_friend', 'friend', 'contact']
: ['contact'];
for (const kind of relationKinds) {
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
const contactsPayload = await authService.listContacts();
setContacts(
contactsPayload?.contacts
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|| [],
);
notifyUnreadStateUpdated();
showToast('Чат удалён', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
// Друг/близкий друг может иметь одновременно и более слабые флаги связи.
// Для настоящего «удалить чат» снимаем весь социальный стек, иначе пустой чат
// закономерно останется в списке из-за действующей связи.
const relationKinds = relationBeforeDelete === 'close_friend' || relationBeforeDelete === 'friend'
? ['close_friend', 'friend', 'contact']
: ['contact'];
for (const kind of relationKinds) {
await authService.setUserRelation({
login: state.session.login,
toLogin: chatId,
kind,
enabled: false,
storagePwd: state.session.storagePwdInMemory,
});
}
const contactsPayload = await authService.listContacts();
setContacts(
contactsPayload?.contacts
|| contactsPayload?.dialogs?.filter((x) => x?.relationFlag !== 'none').map((x) => x.peerLogin)
|| [],
);
notifyUnreadStateUpdated();
showToast('Чат удалён', { timeoutMs: 1200 });
navigate('messages-list');
} catch (error) {
showToast(`Не удалось удалить чат: ${error?.message || 'unknown'}`, { kind: 'error', timeoutMs: 1600 });
}
},
});
},
});
},
},
});
].filter(Boolean),
},
},
],
});
chatHeader.addCleanup(chatHeaderParts.cleanup);
chrome?.setTopbar(chatHeader);
@@ -1788,8 +1682,6 @@ export function render({ navigate, route, chrome }) {
window.removeEventListener('shine-dm-delivery-updated', handleDeliveryRefresh);
boundScrollContainer?.removeEventListener('scroll', handleHistoryScroll);
clearUnreadSeparatorHideTimer();
document.body.classList.remove('chat-topbar-overlay');
chrome?.setComposer(null);
};
return screen;
}
+11 -8
View File
@@ -1,19 +1,17 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'connect-device-view', title: 'Подключить устройство' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить устройство',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
@@ -148,6 +146,11 @@ export function render({ navigate }) {
}
});
screen.append(card, helpModal);
screen.append(card);
const modalRoot = document.getElementById('modal-root');
modalRoot?.append(helpModal);
screen.cleanup = () => {
helpModal.remove();
};
return screen;
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -58,7 +58,7 @@ function createSearchAvatar(login) {
return avatarEl;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-search-screen';
let searchTimer = 0;
@@ -172,11 +172,11 @@ export function render({ navigate }) {
resultsCard.append(status, resultsList);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Поиск контактов',
leftAction: { label: '←', onClick: () => navigate('messages-list') },
}),
back: { label: '←', onClick: () => navigate('messages-list') },
}));
screen.append(
formCard,
resultsCard,
);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, state } from '../state.js';
import {
isClientErrorReportingEnabled,
@@ -246,16 +246,14 @@ function openUiErrorReportingModal() {
});
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки разработчика',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const card = document.createElement('div');
card.className = 'card stack settings-developer-card';
+5 -7
View File
@@ -1,17 +1,15 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
export const pageMeta = { id: 'device-camera-view', title: 'Подключить через камеру' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить через камеру',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const frame = document.createElement('div');
frame.className = 'camera-shell';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -165,7 +165,7 @@ function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
} catch {}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
let savedKeys = null;
@@ -178,12 +178,10 @@ export function render({ navigate }) {
let dialogMode = '';
let pendingTransferRequest = null;
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Подключить по коду',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const transferDialog = document.createElement('div');
transferDialog.className = 'pairing-transfer-dialog';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
import {
@@ -9,16 +9,14 @@ import {
export const pageMeta = { id: 'device-qr-view', title: 'Показать QR-код' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Показать QR-код',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
back: { label: '←', onClick: () => navigate('connect-device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack qr-card';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
isSessionInvalidError,
@@ -31,19 +31,17 @@ function formatOnlineStatus(onlineOnThisServer) {
return onlineOnThisServer ? 'Online now on this server' : 'Offline on this server';
}
export function render({ navigate, route }) {
export function render({navigate, route, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
const sessionId = route?.params?.sessionId || '';
const session = (state.sessions || []).find((item) => item.sessionId === sessionId) || state.sessions[0];
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Сеанс устройства',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
if (!session) {
const empty = document.createElement('div');
+6 -8
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
refreshSessions,
@@ -40,16 +40,14 @@ function sortSessionsByOnline(sessions = []) {
});
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Устройства',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const actions = document.createElement('div');
actions.className = 'card stack';
@@ -75,7 +73,7 @@ export function render({ navigate }) {
const createSessionItem = (session, isCurrent) => {
const item = document.createElement('button');
item.className = 'session-item';
item.className = 'ui-button session-item';
item.type = 'button';
const sessionTypeText = formatSessionType(session.sessionType);
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
+2 -2
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { formatSol, getBalanceSol, transferSol, createSolanaWalletFromPrivateBase58 } from '../services/solana-wallet-service.js';
export const pageMeta = { id: 'devnet-topup-view', title: 'Пополнение DEVNET', showAppChrome: false };
@@ -181,7 +181,7 @@ export function render() {
})();
screen.append(
renderHeader({
createTopBar({
title: 'DEVNET пополнение',
}),
senderBox,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authorizeLocalDemoSession,
isLocalDemoAvailable,
@@ -173,9 +173,9 @@ export function render({ navigate }) {
actions.append(serverUiButton, cancelButton, saveButton);
screen.append(
renderHeader({
createTopBar({
title: 'Настройки входа',
leftAction: { label: '←', onClick: () => navigate('start-view') },
back: { label: '←', onClick: () => navigate('start-view') },
}),
body,
actions,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authorizeSession, state } from '../state.js';
export const pageMeta = { id: 'key-storage-view', title: 'Какие ключи сохранить', showAppChrome: false };
@@ -91,9 +91,9 @@ export function render({ navigate }) {
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
createTopBar({
title: 'Какие ключи сохранить',
leftAction: { label: '←', onClick: () => navigate('login-password-view') },
back: { label: '←', onClick: () => navigate('login-password-view') },
}),
card,
actions,
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { saveEntryLanguage, state } from '../state.js';
export const pageMeta = { id: 'language-view', title: 'Язык' };
@@ -8,21 +8,19 @@ function resolveReturnPage() {
return stored === 'start-view' ? 'start-view' : 'settings-view';
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack language-screen';
const returnPage = resolveReturnPage();
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Язык / Language',
leftAction: { label: '←', onClick: () => {
back: { label: '←', onClick: () => {
sessionStorage.removeItem('shine-language-return-page');
navigate(returnPage);
} },
}),
);
}));
const card = document.createElement('div');
card.className = 'card stack language-choice-card';
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -244,9 +244,9 @@ export function render({ navigate }) {
});
screen.append(
renderHeader({
createTopBar({
title: 'Войти по QR-коду',
leftAction: {
back: {
label: '←',
onClick: () => {
stopCamera();
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
clearAuthMessages,
@@ -170,9 +170,9 @@ export function render({ navigate }) {
panel.append(title, passwordField, status, actions);
screen.append(
renderHeader({
createTopBar({
title: '',
leftAction: { label: '←', onClick: () => navigate('login-view') },
back: { label: '←', onClick: () => navigate('login-view') },
}),
panel,
overlay,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
@@ -154,9 +154,9 @@ export function render({ navigate }) {
panel.append(title, loginField, status, remoteWrap, actions);
screen.append(
renderHeader({
createTopBar({
title: '',
leftAction: {
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
+37 -117
View File
@@ -10,6 +10,7 @@ import {
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { createShineConnectionsLogo } from '../components/shine-logo.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
@@ -200,34 +201,19 @@ export function render({ navigate, chrome }) {
const login = String(state.session.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack dm-screen dm-list-screen';
const head = document.createElement('header');
head.className = 'dm-head';
head.innerHTML = `
<div class="dm-head-brand">
<span class="dm-head-logo-wrap" aria-hidden="true"></span>
</div>
<button type="button" class="dm-head-title dm-head-filter-title" id="dm-chat-filter-title">Чаты</button>
<div class="dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn" aria-label="Меню чатов" aria-haspopup="menu" aria-expanded="false"></button>
<div class="dm-head-menu" role="menu" hidden>
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
</div>
</div>
`;
head.querySelector('.dm-head-logo-wrap')?.append(
createShineConnectionsLogo({ className: 'dm-head-logo' }),
);
const menuButton = head.querySelector('.dm-head-menu-btn');
menuButton?.append(createOverflowDots());
const brand = document.createElement('div');
brand.className = 'dm-head-brand';
const logoWrap = document.createElement('span');
logoWrap.className = 'dm-head-logo-wrap';
logoWrap.setAttribute('aria-hidden', 'true');
logoWrap.append(createShineConnectionsLogo({ className: 'dm-head-logo' }));
brand.append(logoWrap);
let currentChatFilter = 'all';
const filterTitle = head.querySelector('#dm-chat-filter-title');
const filterTitle = document.createElement('button');
filterTitle.type = 'button';
filterTitle.className = 'dm-head-filter-title';
filterTitle.textContent = 'Чаты';
const filterLabels = {
all: 'Чаты',
close_friend: 'Близкие друзья',
@@ -237,8 +223,9 @@ export function render({ navigate, chrome }) {
};
let reloadForFilter = () => {};
const chatFilterMenu = createDropdownMenu({
transparent: true,
anchorEl: filterTitle,
align: 'left',
placement: 'bottom-start',
leftShift: 72,
minWidth: 225,
items: [
@@ -250,92 +237,31 @@ export function render({ navigate, chrome }) {
],
});
const menuWrap = head.querySelector('.dm-head-menu-wrap');
const menuTemplate = head.querySelector('.dm-head-menu');
// The header itself is mounted into chrome/topbar, whose hit-test area ends at the
// header bounds. A dropdown overflowing below it can look visible but clicks may
// land on the content layer underneath. Render the open menu as a body portal.
menuTemplate?.remove();
let menuPortal = null;
const closeHeadMenu = () => {
menuPortal?.remove();
menuPortal = null;
menuButton?.setAttribute('aria-expanded', 'false');
menuWrap?.classList.remove('is-open');
};
const positionHeadMenu = () => {
if (!menuPortal || !menuButton) return;
const rect = menuButton.getBoundingClientRect();
const margin = 10;
const menuWidth = menuPortal.offsetWidth || 206;
const left = Math.max(margin, Math.min(window.innerWidth - menuWidth - margin, rect.right - menuWidth));
menuPortal.style.left = `${Math.round(left)}px`;
const titleRect = filterTitle?.getBoundingClientRect?.();
menuPortal.style.top = `${Math.round((titleRect?.bottom || rect.bottom) + 7)}px`;
};
const openHeadMenu = () => {
if (!menuButton || menuPortal) return;
document.dispatchEvent(new CustomEvent('shine:dropdown-open', { detail: { owner: menuButton } }));
const portal = document.createElement('div');
portal.className = 'dm-head-menu dm-head-menu--portal';
portal.setAttribute('role', 'menu');
portal.innerHTML = `
<button type="button" class="dm-head-menu-item" role="menuitem" data-action="search-contacts">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
<span>Поиск пользователей</span>
</button>
`;
portal.querySelector('[data-action="search-contacts"]')?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
closeHeadMenu();
navigate('contact-search-view');
});
portal.addEventListener('click', (event) => event.stopPropagation());
document.body.append(portal);
menuPortal = portal;
menuButton.setAttribute('aria-expanded', 'true');
menuWrap?.classList.add('is-open');
positionHeadMenu();
};
menuButton?.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (menuPortal) closeHeadMenu();
else openHeadMenu();
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const head = createTopBar({
left: brand,
center: filterTitle,
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню чатов',
ariaLabel: 'Меню чатов',
className: 'messages-topbar-menu-btn',
menu: {
minWidth: 210,
items: [
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
],
},
},
],
});
const onOutsideClick = (event) => {
if (!menuPortal) return;
if (menuPortal.contains(event.target) || menuButton?.contains(event.target)) return;
closeHeadMenu();
};
const onPeerDropdownOpen = (event) => {
if (!menuPortal || event?.detail?.owner === menuButton) return;
closeHeadMenu();
};
const onMenuKeydown = (event) => {
if (event.key !== 'Escape' || !menuPortal) return;
closeHeadMenu();
menuButton?.focus();
};
const onMenuViewportChange = () => positionHeadMenu();
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onMenuKeydown);
document.addEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.addEventListener('resize', onMenuViewportChange, { passive: true });
window.addEventListener('scroll', onMenuViewportChange, { passive: true, capture: true });
const list = document.createElement('div');
list.className = 'stack dm-list';
@@ -522,13 +448,7 @@ function renderRow(item) {
loadList();
screen.cleanup = () => {
closeHeadMenu();
chatFilterMenu.destroy();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onMenuKeydown);
document.removeEventListener('shine:dropdown-open', onPeerDropdownOpen);
window.removeEventListener('resize', onMenuViewportChange);
window.removeEventListener('scroll', onMenuViewportChange, true);
};
return screen;
+28 -25
View File
@@ -1,5 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { createTopBar } from '../components/topbar.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { authService, state } from '../state.js';
import { makeProfileRoute } from '../services/shine-routes.js';
@@ -9,7 +8,18 @@ import { engineModelFromGraphModel } from './network/adapter.js';
import { openNodeMenu } from './network/node-menu.js';
import { userDisplayName } from '../services/user-display.js';
export const pageMeta = { id: 'network-view', title: 'Связи' };
export const pageMeta = {
id: 'network-view',
title: 'Связи',
shellMode: {
topFade: true,
bottomFade: true,
bottomFadeAnchor: 'toolbar',
fadeProfile: 'edge',
contentUnderTopbar: true,
scrollContainer: 'locked',
},
};
const GENDER_MALE = 'male';
const GENDER_FEMALE = 'female';
@@ -202,8 +212,6 @@ export function render({ navigate, route, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'network-screen';
const appScreenEl = document.getElementById('app-screen');
appScreenEl?.classList.add('network-scroll-lock');
const stage = document.createElement('div');
stage.className = 'network-stage';
@@ -434,39 +442,34 @@ export function render({ navigate, route, chrome } = {}) {
}
}
const header = renderHeader({
title: 'Связи',
rightActions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
onClick: () => {},
},
],
});
const networkMenuButton = header.querySelector('.network-header-menu-btn');
const searchIconHtml = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="6.5"></circle>
<path d="M16 16l4 4"></path>
</svg>
`;
const networkMenu = createDropdownMenu({
anchorEl: networkMenuButton,
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
const header = createTopBar({
title: 'Связи',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню связей',
ariaLabel: 'Открыть меню связей',
className: 'chat-header-icon-btn network-header-menu-btn',
menu: {
minWidth: 220,
items: [
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
],
},
},
],
});
// Ресайз и перерисовку рёбер движок обрабатывает сам (window resize + ResizeObserver внутри).
screen.cleanup = () => {
networkMenu.destroy();
if (engine) engine.destroy();
engine = null;
appScreenEl?.classList.remove('network-scroll-lock');
};
if (routeLogin) {
+4 -4
View File
@@ -534,7 +534,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
// лёгкая точка для узлов сверх лимита: без аватара и подписи (производительность)
if (dotOnly) {
el.className = [
'fg-node', 'fg-dot',
'ui-button', 'fg-node', 'fg-dot',
tier >= 3 ? 'is-tier3' : '', // микрозвезда 3-го уровня (светящаяся мерцающая точка)
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -546,7 +546,7 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
return el;
}
el.className = [
'fg-node',
'ui-button', 'fg-node',
isFocus ? 'is-focus' : '',
src.shining ? 'is-shine' : '',
`is-${src.relationType || 'contact'}`,
@@ -628,8 +628,8 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
node.dotRadius = spec.isFocus ? 32 : (tier >= 3 ? 5 : (tier === 2 ? 16 : (spec.dotOnly ? 7 : 26)));
// обновляем классы элемента (роль/тип/свечение/уровень) — без пересоздания DOM
node.el.className = spec.dotOnly
? ['fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
? ['ui-button', 'fg-node', 'fg-dot', tier >= 3 ? 'is-tier3' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`].filter(Boolean).join(' ')
: ['ui-button', 'fg-node', spec.isFocus ? 'is-focus' : '', src.shining ? 'is-shine' : '', `is-${src.relationType || 'contact'}`, tier === 2 ? 'is-tier2' : '', tier >= 2 ? 'is-secondary' : '', src.common ? 'is-common' : ''].filter(Boolean).join(' ');
}
// --- Рендер ----------------------------------------------------------------
+1 -1
View File
@@ -36,7 +36,7 @@ export function openNodeMenu({ login, displayName = '', relationType, point, act
if (!(root instanceof HTMLElement)) return;
const itemsHtml = actions
.map((a, i) => `<button class="fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
.map((a, i) => `<button class="ui-button fg-menu-item${a.disabled ? ' is-stub' : ''}" type="button" data-i="${i}" role="menuitem"${a.disabled ? ' disabled' : ''}>${escapeHtml(a.label)}</button>`)
.join('');
root.innerHTML = `
+2 -2
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
import { loadProfileSnapshot } from '../services/user-profile-params.js';
@@ -320,7 +320,7 @@ function renderItem(item, activeTab, navigate) {
export function render({ navigate, chrome } = {}) {
const screen = document.createElement('section');
screen.className = 'stack notifications-screen';
chrome?.setTopbar(renderHeader({ title: 'Уведомления' }));
chrome?.setTopbar(createTopBar({ title: 'Уведомления' }));
const tabs = document.createElement('div');
tabs.className = 'notification-feed-tabs app-top-tabs';
+4 -4
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
@@ -107,9 +107,9 @@ export function render({ navigate, chrome }) {
screen.className = 'stack profile-screen';
chrome?.setTopbar(
renderHeader({
createTopBar({
title: 'Редактирование профиля',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
back: { label: '←', onClick: () => navigate('profile-view') },
}),
);
@@ -454,7 +454,7 @@ export function render({ navigate, chrome }) {
}
suggestEl.hidden = false;
suggestEl.innerHTML = values.map((value) => (
`<button type="button" class="profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
`<button type="button" class="ui-button profile-relative-suggest-item" data-login="${escapeHtml(value)}">${escapeHtml(value)}</button>`
)).join('');
};
+160 -144
View File
@@ -1,12 +1,9 @@
import { profile } from '../mock-data.js';
import { authService, state } from '../state.js';
import {
loadProfileSnapshot,
} from '../services/user-profile-params.js';
import { state } from '../state.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { createOverflowDots } from '../components/overflow-dots.js';
import { createDropdownMenu } from '../components/dropdown-menu.js';
import { userDisplayName } from '../services/user-display.js';
import { createTopBar } from '../components/topbar.js';
import { loadUserProfileCard } from '../services/user-connections.js';
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
@@ -19,172 +16,194 @@ function escapeHtml(text) {
.replaceAll("'", '&#39;');
}
function fieldMap(snapshot) {
const out = {};
(Array.isArray(snapshot?.fields) ? snapshot.fields : []).forEach((field) => {
out[String(field?.key || '').trim()] = String(field?.value || '').trim();
});
return out;
function metricHtml({ kind, label, value, glow = false, positionClass = '' }) {
const numericValue = Number(value || 0);
return `
<button
type="button"
class="user-profile-metric ${positionClass}${glow ? ' is-glowing' : ''}"
data-profile-list="${escapeHtml(kind)}"
aria-label="${escapeHtml(label)}: ${numericValue}"
>
<span class="user-profile-metric-circle">${numericValue}</span>
<span class="user-profile-metric-label">${escapeHtml(label)}</span>
</button>`;
}
function openTextModal(title, text) {
const root = document.getElementById('modal-root');
if (!root) return;
root.innerHTML = `
<div class="modal" id="profile-text-modal">
<div class="modal-card stack">
<h3>${escapeHtml(title)}</h3>
<div style="white-space:pre-wrap;line-height:1.5">${escapeHtml(text || 'Не заполнено')}</div>
<button class="secondary-btn" id="profile-text-close">Закрыть</button>
</div>
</div>`;
const close = () => { root.innerHTML = ''; };
root.querySelector('#profile-text-close')?.addEventListener('click', close);
root.querySelector('#profile-text-modal')?.addEventListener('click', (event) => {
if (event.target?.id === 'profile-text-modal') close();
});
function spiritualPathDetailHtml(card) {
const value = String(card?.spiritualPath || '').trim();
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
}
function statusBadges(accountRole, shineStatus) {
const role = accountRole === 'primary' ? 'Основной аккаунт' : accountRole === 'non_voting' ? 'Голос не учитывать' : '';
const shine = shineStatus === 'shining' ? 'Сияющий' : '';
return `<div class="row wrap-row">
${role ? `<span class="badge">${escapeHtml(role)}</span>` : ''}
${shine ? '<span class="badge is-yes-shine">Сияющий</span>' : ''}
${shineStatus === 'not_interested' ? '<span class="profile-shine-not-interested" title="Сияние неинтересно"><img src="/assets/shine-status-not-interested.svg" alt="Сияние неинтересно"></span>' : ''}
</div>`;
}
function contactsDetailHtml(card) {
const rows = [
['Ссылки', card?.web],
['Телефон', card?.phone],
['Адрес', card?.address],
].filter(([, value]) => String(value || '').trim());
function statsRows(stats = {}) {
return [
['friends', 'Друзья', stats.friendsCount],
['close_friends', 'Близкие друзья', stats.closeFriendsCount],
['primary_received', 'Подтвердили основной аккаунт', stats.primaryReceivedCount],
['primary_given', 'Подтверждённые аккаунты', stats.primaryGivenCount],
['shine_received', 'Считают сияющим', stats.shineReceivedCount],
['shine_given', 'Подтверждённые сияющие', stats.shineGivenCount],
['channels_following', 'Подписки на каналы', stats.followingChannelsCount],
['channels_owned', 'Каналы', stats.ownedPublicChannelsCount],
];
if (!rows.length) {
return '<div class="user-profile-detail-copy is-muted">Не заполнено</div>';
}
return rows.map(([label, value]) => `
<div class="user-profile-contact-row">
<span>${escapeHtml(label)}</span>
<b>${escapeHtml(value)}</b>
</div>`).join('');
}
export function render({ navigate, chrome }) {
const login = String(state.session.login || profile.login || '').trim();
const screen = document.createElement('section');
screen.className = 'stack profile-screen';
screen.className = 'stack user-profile-screen';
const topbar = document.createElement('header');
topbar.className = 'page-header app-topbar-shell app-topbar-shell--profile';
topbar.innerHTML = `
<div class="header-left" aria-hidden="true"></div>
<div class="header-center"><h1 class="page-title">Профиль</h1></div>
<div class="header-actions profile-head-menu-wrap dm-head-menu-wrap">
<button type="button" class="dm-head-menu-btn profile-head-menu-btn" aria-label="Меню профиля" aria-haspopup="menu" aria-expanded="false"></button>
</div>`;
const menuButton = topbar.querySelector('.profile-head-menu-btn');
menuButton?.append(createOverflowDots());
const profileMenu = createDropdownMenu({
anchorEl: menuButton,
className: 'profile-head-menu',
minWidth: 230,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
const topbar = createTopBar({
title: login || 'Профиль',
className: 'topbar--profile user-profile-header',
actions: [
{
iconNode: createOverflowDots(),
title: 'Меню профиля',
ariaLabel: 'Меню профиля',
className: 'profile-head-menu-btn',
menu: {
className: 'profile-head-menu',
minWidth: 250,
items: [
{ label: 'Редактировать профиль', iconSrc: '/assets/profile-icon-profile.svg', action: () => navigate('profile-edit-view') },
{ label: 'Кошелёк', iconSrc: '/assets/profile-icon-wallet.svg', action: () => navigate('wallet-view') },
{ label: 'Настройки', iconSrc: '/assets/profile-icon-settings.svg', action: () => navigate('settings-view') },
{ label: 'Подтверждённые аккаунты', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/primary_given`) },
{ label: 'Подтверждённые сияющие', action: () => navigate(`SHiNE/${encodeURIComponent(login)}/list/shine_given`) },
{ label: 'Сменить профиль', action: () => navigate('profiles-view') },
],
},
},
],
});
chrome?.setTopbar(topbar);
const status = document.createElement('div');
status.className = 'status-line';
status.className = 'status-line user-profile-status';
status.textContent = 'Загрузка профиля...';
const body = document.createElement('div');
body.className = 'stack';
body.className = 'user-profile-body';
screen.append(status, body);
let current = null;
let card = null;
function renderProfile() {
if (!current) return;
const { snapshot, user } = current;
const fields = fieldMap(snapshot);
const firstName = fields.first_name || '';
const lastName = fields.last_name || '';
const displayName = userDisplayName({ login, firstName, lastName });
const avatar = snapshot?.avatar?.txId
? { ar: String(snapshot.avatar.txId).trim(), sha256Hex: String(snapshot?.avatar?.sha256Hex || '').trim().toLowerCase() }
: null;
const stats = {
ownedPublicChannelsCount: Number(user?.ownedPublicChannelsCount || 0),
followingChannelsCount: Number(user?.followingChannelsCount || 0),
friendsCount: Number(user?.friendsCount || 0),
closeFriendsCount: Number(user?.closeFriendsCount || 0),
primaryReceivedCount: Number(user?.primaryConfirmationsReceivedCount || 0),
primaryGivenCount: Number(user?.primaryConfirmationsGivenCount || 0),
shineReceivedCount: Number(user?.shineConfirmationsReceivedCount || 0),
shineGivenCount: Number(user?.shineConfirmationsGivenCount || 0),
};
if (!card) return;
const stats = card.stats || {};
const official = card.accountRole === 'primary';
const shining = card.shineStatus === 'shining';
const fullName = [card.firstName, card.lastName]
.map((value) => String(value || '').trim())
.filter(Boolean)
.join(' ');
const about = String(card.about || '').trim();
body.innerHTML = '';
const identity = document.createElement('div');
identity.className = 'card row';
identity.style.gap = '12px';
identity.style.alignItems = 'center';
identity.append(renderUserAvatar({ login, firstName, lastName, avatar, size: 'xl', className: 'profile-avatar' }));
const identityText = document.createElement('div');
identityText.innerHTML = `<div class="profile-identity-line">${escapeHtml(displayName)}</div><div class="profile-identity-login">${escapeHtml(login)}</div>`;
identity.append(identityText);
body.append(identity);
const title = topbar.querySelector('.topbar__title');
if (title) title.textContent = card.login || login || 'Профиль';
const badges = document.createElement('div');
badges.innerHTML = statusBadges(String(snapshot?.accountRole || '').trim().toLowerCase(), String(snapshot?.shineStatus || '').trim().toLowerCase());
body.append(...badges.children);
body.innerHTML = `
${fullName ? `<div class="user-profile-full-name">${escapeHtml(fullName)}</div>` : ''}
if (fields.about) {
const about = document.createElement('div');
about.className = 'card profile-about';
about.style.whiteSpace = 'pre-wrap';
about.textContent = fields.about;
body.append(about);
}
<div class="user-profile-hero" aria-label="Профиль ${escapeHtml(card.login)}">
${metricHtml({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, glow: official, positionClass: 'is-top-left' })}
${metricHtml({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, glow: shining, positionClass: 'is-top-right' })}
<div class="user-profile-avatar-slot"></div>
${metricHtml({ kind: 'friends', label: 'Друзья', value: stats.friendsCount, positionClass: 'is-bottom-left' })}
${metricHtml({ kind: 'close_friends', label: 'Близкие друзья', value: stats.closeFriendsCount, positionClass: 'is-bottom-right' })}
</div>
const statsGrid = document.createElement('div');
statsGrid.className = 'profile-stats-grid';
statsRows(stats).forEach(([kind, label, value]) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'card profile-stat-card';
button.dataset.profileList = kind;
button.innerHTML = `<b>${Number(value || 0)}</b><span>${escapeHtml(label)}</span>`;
statsGrid.append(button);
});
body.append(statsGrid);
${about ? `<div class="user-profile-about-inline">${escapeHtml(about)}</div>` : ''}
const detailRow = document.createElement('div');
detailRow.className = 'row wrap-row';
detailRow.innerHTML = '<button class="secondary-btn" data-profile-detail="contacts">Контакты</button><button class="secondary-btn" data-profile-detail="spiritual">Духовный путь</button>';
body.append(detailRow);
<div class="user-profile-channel-metrics">
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-channel-metric' })}
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-channel-metric' })}
</div>
<div class="user-profile-actions-wrap">
<div class="user-profile-actions" aria-label="Действия со своим профилем">
<button type="button" class="user-profile-action-btn" data-self-profile-action="edit" aria-label="Редактировать профиль" title="Редактировать профиль">
<img src="/assets/profile-icon-profile.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="wallet" aria-label="Кошелёк" title="Кошелёк">
<img src="/assets/profile-icon-wallet.svg" alt="" aria-hidden="true">
</button>
<button type="button" class="user-profile-action-btn" data-self-profile-action="settings" aria-label="Настройки" title="Настройки">
<img src="/assets/profile-icon-settings.svg" alt="" aria-hidden="true">
</button>
</div>
</div>
<div class="user-profile-detail-links" aria-label="Дополнительная информация о профиле">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="profile-view-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>`;
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
avatarSlot?.append(renderUserAvatar({
login: card.login,
firstName: card.firstName,
lastName: card.lastName,
avatar: card.avatar,
size: 'xl',
className: 'user-profile-hero-avatar',
glow: shining,
}));
status.textContent = '';
}
body.addEventListener('click', (event) => {
if (!current) return;
const el = event.target.closest('[data-profile-list],[data-profile-detail]');
if (!el) return;
const kind = el.dataset.profileList;
if (kind) {
navigate(`SHiNE/${encodeURIComponent(login)}/list/${encodeURIComponent(kind)}`);
if (!card) return;
const listButton = event.target.closest('[data-profile-list]');
if (listButton) {
const kind = listButton.dataset.profileList;
if (kind) navigate(`SHiNE/${encodeURIComponent(card.login)}/list/${encodeURIComponent(kind)}`);
return;
}
const fields = fieldMap(current.snapshot);
if (el.dataset.profileDetail === 'contacts') {
openTextModal('Контакты', [
fields.web ? `Links: ${fields.web}` : '',
fields.phone ? `Телефон: ${fields.phone}` : '',
fields.address ? `Адрес: ${fields.address}` : '',
].filter(Boolean).join('\n') || 'Не заполнено');
const actionButton = event.target.closest('[data-self-profile-action]');
if (actionButton) {
const action = actionButton.dataset.selfProfileAction;
if (action === 'edit') navigate('profile-edit-view');
if (action === 'wallet') navigate('wallet-view');
if (action === 'settings') navigate('settings-view');
return;
}
if (el.dataset.profileDetail === 'spiritual') openTextModal('Духовный путь', fields.spiritual_path);
const detailButton = event.target.closest('[data-profile-detail]');
if (!detailButton) return;
const detailKind = detailButton.dataset.profileDetail;
const detailPanel = body.querySelector('#profile-view-detail-panel');
if (!detailPanel) return;
body.querySelectorAll('[data-profile-detail]').forEach((button) => {
const active = button === detailButton;
button.classList.toggle('is-active', active);
button.setAttribute('aria-pressed', active ? 'true' : 'false');
});
if (detailKind === 'spiritual-path') {
detailPanel.innerHTML = spiritualPathDetailHtml(card);
} else if (detailKind === 'contacts') {
detailPanel.innerHTML = contactsDetailHtml(card);
} else {
return;
}
detailPanel.hidden = false;
detailPanel.dataset.activeDetail = detailKind;
});
async function refresh() {
@@ -192,17 +211,14 @@ export function render({ navigate, chrome }) {
status.textContent = 'Локальный тестовый режим.';
return;
}
const [snapshot, user] = await Promise.all([loadProfileSnapshot(login), authService.getUser(login)]);
current = { snapshot, user };
card = await loadUserProfileCard(login);
renderProfile();
status.textContent = '';
}
refresh().catch((error) => {
status.className = 'status-line is-unavailable';
status.className = 'status-line user-profile-status is-unavailable';
status.textContent = `Ошибка: ${error?.message || 'unknown'}`;
});
screen.cleanup = () => profileMenu.destroy();
return screen;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
closeAllSavedProfiles,
closeSavedProfile,
@@ -15,13 +15,13 @@ function reloadTo(path) {
window.location.assign(clean.startsWith('/') ? clean : `/${clean}`);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack profiles-screen';
screen.append(renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Профили',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const intro = document.createElement('div');
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { defaultSolanaCluster } from '../deploy-config.js';
import { state } from '../state.js';
import { loadSolanaWeb3 } from '../vendor/solana-web3-loader.js';
@@ -262,7 +262,7 @@ function readTicketFromUrl() {
}
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -404,11 +404,11 @@ export function render({ navigate }) {
});
content.append(card, mainnetRow, inputLabel, queryInput, actions, result, status);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Очередь билета',
leftAction: { label: '←', onClick: () => navigate('start-view') },
}),
back: { label: '←', onClick: () => navigate('start-view') },
}));
screen.append(
content,
);
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { canInstallPwa, isStandalonePwaMode } from '../services/pwa-install-service.js';
@@ -265,16 +265,14 @@ function buildRecommendations(diag) {
return rows;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Диагностика PWA / Push',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
const statusCard = document.createElement('div');
statusCard.className = 'card stack';
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, clearAuthMessages, resetRegistrationFlow, state } from '../state.js';
import { toUserMessage } from '../services/ui-error-texts.js';
import {
@@ -424,9 +424,9 @@ export function render({ navigate }) {
renderInputStage();
screen.append(
renderHeader({
createTopBar({
title: 'Зарегистрироваться',
leftAction: {
back: {
label: '←',
onClick: () => {
resetRegistrationFlow();
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { base64ToBytes, bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
@@ -178,9 +178,9 @@ export function render({ navigate }) {
actions.append(backButton);
screen.append(
renderHeader({
createTopBar({
title: 'Сгенерированные ключи',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
back: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
actions,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
export const pageMeta = { id: 'registration-faq-view', title: 'Вопросы о регистрации', showAppChrome: false };
@@ -215,9 +215,9 @@ export function render({ navigate }) {
actions.append(backButton, registerButton);
screen.append(
renderHeader({
createTopBar({
title: 'Вопросы о регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
back: { label: '←', onClick: () => navigate('register-view') },
}),
heroCard,
topicCard,
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
cancelAddProfileLogin,
@@ -193,9 +193,9 @@ export function render({ navigate }) {
actions.append(cancelButton, okButton);
screen.append(
renderHeader({
createTopBar({
title: 'Сохранение ключей',
leftAction: {
back: {
label: '←',
onClick: async () => {
if (state.authReturnHash === '/profiles') {
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
authService,
authorizeSession,
@@ -374,9 +374,9 @@ export function render({ navigate }) {
card.append(showKeysButton, submitButton, status);
screen.append(
renderHeader({
createTopBar({
title: 'Оплата регистрации',
leftAction: { label: '←', onClick: () => navigate('register-view') },
back: { label: '←', onClick: () => navigate('register-view') },
}),
card,
);
@@ -398,7 +398,7 @@ export function render({ navigate }) {
function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
const headerBackButton = screen.querySelector('.topbar .topbar__back');
const card = screen.querySelector('.card.stack');
if (!card) return;
card.classList.add('registration-finish-card');
@@ -585,7 +585,7 @@ function renderSolanaRegistrationStage({ navigate, status, keyBundle, registrati
function renderSolanaDoneStage({ navigate, status, keyBundle, registrationTxId = '' }) {
const screen = document.querySelector('section.stack');
if (!screen) return;
const headerBackButton = screen.querySelector('.page-header .header-left .icon-btn');
const headerBackButton = screen.querySelector('.topbar .topbar__back');
const card = screen.querySelector('.card.stack');
if (!card) return;
card.classList.add('registration-finish-card');
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
isSessionInvalidError,
refreshSessions,
@@ -29,16 +29,14 @@ function sessionLabel(session) {
return `Homeserver ${String(session?.sessionId || '').slice(0, 12)}`;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'AddBlock через homeserver',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
);
back: { label: '←', onClick: () => navigate('settings-view') },
}));
const actions = document.createElement('div');
actions.className = 'card stack';
@@ -69,7 +67,7 @@ export function render({ navigate }) {
sessions.forEach((session) => {
const sessionId = String(session?.sessionId || '').trim();
const item = document.createElement('button');
item.className = 'session-item';
item.className = 'ui-button session-item';
item.type = 'button';
const isSelected = sessionId && sessionId === selectedId;
item.innerHTML = `
+7 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { saveEntrySettings, state } from '../state.js';
import { checkServerAvailabilityByKey, resolveAndCheckShineServerLogin } from '../services/server-health-service.js';
@@ -10,7 +10,7 @@ const SERVER_FIELDS = [
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
];
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -185,7 +185,7 @@ export function render({ navigate }) {
actions.append(cancelButton, saveButton);
const help = document.createElement('button');
help.className = 'help-fab';
help.className = 'ui-button help-fab';
help.type = 'button';
help.textContent = '?';
help.addEventListener('click', () => {
@@ -194,11 +194,11 @@ export function render({ navigate }) {
);
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Серверы блокчейнов',
leftAction: { label: '←', onClick: () => navigate('settings-view') },
}),
back: { label: '←', onClick: () => navigate('settings-view') },
}));
screen.append(
introCard,
body,
actions,
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
@@ -25,17 +25,15 @@ function formatVersionForUi(rawValue) {
return value;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
let isDisposed = false;
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
);
back: { label: '←', onClick: () => navigate('profile-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
+5 -7
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import { bytesToBase58 } from '../services/crypto-utils.js';
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
@@ -6,7 +6,7 @@ import { loadEncryptedUserSecrets } from '../services/key-vault.js';
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -22,12 +22,10 @@ export function render({ navigate }) {
device: '',
};
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Показать ключи',
leftAction: { label: '←', onClick: () => navigate('device-view') },
}),
);
back: { label: '←', onClick: () => navigate('device-view') },
}));
const card = document.createElement('div');
card.className = 'card stack';
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
export const pageMeta = { id: 'solana-rpc-check-view', title: 'Проверка Solana RPC' };
@@ -132,7 +132,7 @@ function makeResultCard(endpoint) {
return { card, badge, statusLine, details };
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -242,11 +242,11 @@ export function render({ navigate }) {
});
resetBtn.addEventListener('click', resetState);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Проверка Solana RPC',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
screen.append(
intro,
summary,
grid,
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import {
SHINE_USERS_ECONOMY_CONFIG_SEED,
SHINE_USERS_PROGRAM_ID,
@@ -29,7 +29,7 @@ function shortAddr(value = '') {
return `${v.slice(0, 6)}...${v.slice(-6)}`;
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -184,11 +184,11 @@ export function render({ navigate }) {
status,
);
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Solana Init (users)',
leftAction: { label: '←', onClick: () => navigate('developer-settings-view') },
}),
back: { label: '←', onClick: () => navigate('developer-settings-view') },
}));
screen.append(
card,
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { state } from '../state.js';
import {
createSolanaWalletFromPrivateBase58,
@@ -161,9 +161,9 @@ export function render({ navigate }) {
})();
screen.append(
renderHeader({
createTopBar({
title: 'Пополнение solana счета',
leftAction: { label: '←', onClick: () => navigate('registration-payment-view') },
back: { label: '←', onClick: () => navigate('registration-payment-view') },
}),
card,
status,
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
@@ -18,7 +18,7 @@ function describeState(settings) {
return 'Вход через другое устройство разрешён без дополнительного пароля.';
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -182,11 +182,11 @@ export function render({ navigate }) {
}
});
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Настройки входа через устройство',
leftAction: { label: '←', onClick: () => navigate('device-pairing-view') },
}),
back: { label: '←', onClick: () => navigate('device-pairing-view') },
}));
screen.append(
card,
);
+9 -5
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService } from '../state.js';
import { navigateBack } from '../router.js';
@@ -18,13 +18,17 @@ const TITLES = {
channels_owned: 'Каналы', channels_following: 'Подписки на каналы',
};
export function render({ navigate, route }) {
export function render({navigate, route, chrome}) {
const login = String(route?.params?.login || '').trim();
const kind = String(route?.params?.kind || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack';
const status = document.createElement('div'); status.className = 'status-line'; status.textContent = 'Загрузка...';
screen.append(renderHeader({ title: TITLES[kind] || 'Список', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
chrome?.setTopbar(createTopBar({ title: TITLES[kind] || 'Список', back: { label: '←', onClick: () => navigateBack() } }));
screen.append(
status,
body,
);
(async () => {
try {
@@ -32,7 +36,7 @@ export function render({ navigate, route }) {
const payload = await authService.listUserProfileChannels(login, kind === 'channels_owned' ? 'owned' : 'following', 200, 0);
const rows = Array.isArray(payload?.channels) ? payload.channels : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.ownerLogin, firstName: row.displayName, lastName: '', avatar: parseAvatar(row.avatarAr), size: 'md' }));
const t = document.createElement('div'); t.className = 'profile-list-row-text';
t.innerHTML = `<b>${String(row.displayName || row.slug || '')}</b><small>${String(row.ownerLogin || '')} / ${String(row.slug || '')}</small>`;
@@ -46,7 +50,7 @@ export function render({ navigate, route }) {
const payload = await authService.listUserProfileRelations(login, kind, 200, 0);
const rows = Array.isArray(payload?.users) ? payload.users : [];
rows.forEach((row) => {
const el = document.createElement('button'); el.type = 'button'; el.className = 'card row profile-list-row';
const el = document.createElement('button'); el.type = 'button'; el.className = 'ui-button card row profile-list-row';
el.append(renderUserAvatar({ login: row.login, firstName: row.firstName, lastName: row.lastName, avatar: parseAvatar(row.avatarAr), size: 'md' }));
const fullName = userDisplayName(row);
const t = document.createElement('div'); t.className = 'profile-list-row-text';
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { SHINE_CONNECTIONS_LOGO_SRC } from '../components/shine-logo.js';
import { renderUserAvatar } from '../components/avatar-image.js';
import { authService, state } from '../state.js';
@@ -87,9 +87,9 @@ export function render({ navigate, route, chrome }) {
const screen = document.createElement('section');
screen.className = 'stack user-profile-screen';
const header = renderHeader({
const header = createTopBar({
title: requestedLogin || 'Профиль',
leftAction: { label: '←', onClick: () => navigateBack() },
back: { label: '←', onClick: () => navigateBack() },
});
header.classList.add('user-profile-header');
chrome?.setTopbar(header);
@@ -176,7 +176,7 @@ export function render({ navigate, route, chrome }) {
.join(' ');
const about = String(card.about || '').trim();
const title = header.querySelector('.page-title');
const title = header.querySelector('.topbar__title');
if (title) title.textContent = card.login;
body.innerHTML = `
@@ -214,10 +214,10 @@ export function render({ navigate, route, chrome }) {
</div>` : ''}
<div class="user-profile-detail-links" aria-label="Дополнительная информация о пользователе">
<button type="button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<button type="button" class="ui-button" data-profile-detail="spiritual-path" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Духовный путь</span>
</button>
<button type="button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<button type="button" class="ui-button" data-profile-detail="contacts" aria-pressed="false" aria-controls="user-profile-detail-panel">
<span class="user-profile-detail-tab-label">Контакты</span>
</button>
</div>
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { authService, state } from '../state.js';
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
import { navigateBack } from '../router.js';
@@ -7,11 +7,15 @@ export const pageMeta = { id: 'user-relation-manage-view', title: 'Добави
function effectiveSocial(f) { if (f.outCloseFriend) return 'close_friend'; if (f.outFriend) return 'friend'; if (f.outContact) return 'contact'; return 'none'; }
export function render({ route }) {
export function render({route, chrome}) {
const targetLogin = String(route?.params?.login || '').trim(); const selfLogin = String(state.session.login || '').trim();
const screen = document.createElement('section'); screen.className = 'stack';
const body = document.createElement('div'); body.className = 'stack'; const status = document.createElement('div'); status.className = 'status-line';
screen.append(renderHeader({ title: 'Добавить', leftAction: { label: '←', onClick: () => navigateBack() } }), status, body);
chrome?.setTopbar(createTopBar({ title: 'Добавить', back: { label: '←', onClick: () => navigateBack() } }));
screen.append(
status,
body,
);
let flags, selfCard, targetCard;
async function setKind(kind, enabled) { await authService.setUserRelation({ login: selfLogin, toLogin: targetLogin, kind, enabled, storagePwd: state.session.storagePwdInMemory }); }
+6 -6
View File
@@ -1,4 +1,4 @@
import { renderHeader } from '../components/header.js';
import { createTopBar } from '../components/topbar.js';
import { defaultSolanaCluster } from '../deploy-config.js';
import { authService, state } from '../state.js';
import {
@@ -446,7 +446,7 @@ function ticketPdaFor(programId, queueId, index) {
);
}
export function render({ navigate }) {
export function render({navigate, chrome}) {
const screen = document.createElement('section');
screen.className = 'stack';
@@ -459,11 +459,11 @@ export function render({ navigate }) {
const content = document.createElement('div');
content.className = 'stack';
screen.append(
renderHeader({
chrome?.setTopbar(createTopBar({
title: 'Кошелёк',
leftAction: { label: '←', onClick: () => navigate('profile-view') },
}),
back: { label: '←', onClick: () => navigate('profile-view') },
}));
screen.append(
content,
status,
);
+2 -2
View File
@@ -362,12 +362,12 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
slide.className = 'message-attachment-slide';
const prev = document.createElement('button');
prev.type = 'button';
prev.className = 'message-attachment-arrow message-attachment-arrow--prev';
prev.className = 'ui-button message-attachment-arrow message-attachment-arrow--prev';
prev.textContent = '';
prev.setAttribute('aria-label', 'Предыдущее вложение');
const next = document.createElement('button');
next.type = 'button';
next.className = 'message-attachment-arrow message-attachment-arrow--next';
next.className = 'ui-button message-attachment-arrow message-attachment-arrow--next';
next.textContent = '';
next.setAttribute('aria-label', 'Следующее вложение');
const counter = document.createElement('div');
+303
View File
@@ -0,0 +1,303 @@
/*
* App Shell: единая геометрия и глобальные UI-слои приложения.
* Feature-specific стили находятся в styles/features/* и network-graph.css.
*/
.app-shell {
width: min(var(--app-viewport-width, 100vw), 430px);
height: var(--app-viewport-height, 100vh);
position: fixed;
top: var(--app-viewport-offset-top, 0px);
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
transform: translateX(-50%);
--call-minimized-bar-height: 0px;
--topbar-height: 0px;
--composer-height: 0px;
--toolbar-height: 78px;
--keyboard-offset: 0px;
--z-shell-content: 1;
--z-shell-fade: 10;
--z-shell-chrome: 20;
--z-shell-status: 30;
background: transparent;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
box-shadow: var(--shadow);
overflow: hidden;
}
.screen-content {
position: absolute;
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
left: 0;
right: 0;
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
z-index: var(--z-shell-content);
overflow-y: auto;
padding: 14px 14px 24px;
}
.screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0;
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.app-shell.has-minimized-call .screen-content {
padding-top: 12px;
}
.topbar-slot,
.composer-slot,
.toolbar-slot {
z-index: var(--z-shell-chrome);
}
.topbar-slot[hidden],
.composer-slot[hidden],
.toolbar-slot[hidden] {
display: none;
}
.topbar-slot {
position: absolute;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
padding: 0 12px;
pointer-events: none;
}
.topbar-slot > * {
pointer-events: auto;
}
.composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px;
pointer-events: none;
}
.composer-slot > * {
pointer-events: auto;
}
.toolbar-slot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.connection-retry-banner {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(96px + env(safe-area-inset-bottom));
z-index: var(--z-shell-status);
border-radius: 11px;
border: 1px solid rgba(133, 156, 201, 0.3);
background: rgba(10, 19, 37, 0.86);
color: #c6d6f7;
font-size: 12px;
line-height: 1.2;
text-align: center;
padding: 7px 10px;
pointer-events: auto;
cursor: pointer;
backdrop-filter: blur(10px);
}
.connection-retry-banner.is-connected {
border-color: rgba(124, 235, 171, 0.4);
color: #d8ffe9;
}
.connection-retry-banner.is-connecting {
border-color: transparent;
color: #ffe8bb;
}
.connection-retry-banner.is-disconnected {
border-color: rgba(228, 127, 145, 0.44);
color: #ffdce3;
}
.connection-retry-banner.is-updating {
border-color: rgba(144, 201, 255, 0.44);
color: #d9eeff;
}
/* Обычный scroll-container физически продолжается под прозрачным TopBar. */
.app-shell--content-under-topbar:not(.app-shell--scroll-nested):not(.app-shell--scroll-locked) .screen-content:not(.no-app-chrome) {
top: var(--call-minimized-bar-height, 0px);
padding-top: calc(var(--topbar-height, 64px) + 14px);
}
/* Вложенный scroll-container (например, переписка) сам резервирует место под chrome. */
.app-shell--scroll-nested .topbar-slot {
position: fixed;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
width: min(100vw, 430px);
margin: 0 auto;
transform: none;
padding: 0 12px;
}
.app-shell--scroll-nested .screen-content {
top: var(--call-minimized-bar-height, 0px);
padding-top: 0;
}
.app-shell--content-under-bottom:not(.keyboard-open) .screen-content {
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
}
/* Полноэкранный feature сам управляет внутренней геометрией, shell — viewport/слоями. */
.app-shell--scroll-locked .screen-content {
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
padding: 0;
}
.app-shell--scrollbar-hidden .screen-content {
scrollbar-width: none;
-ms-overflow-style: none;
}
.app-shell--scrollbar-hidden .screen-content::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
/* Global shell fades: один механизм, режимы различаются только профилем кривой. */
.app-shell-fade {
position: absolute;
left: 0;
right: 0;
z-index: var(--z-shell-fade);
display: none;
pointer-events: none;
}
.app-shell--top-fade.app-shell--has-topbar .app-shell-fade--top {
display: block;
top: var(--call-minimized-bar-height, 0px);
height: calc(var(--topbar-height, 64px) + 48px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 1) 0%,
rgba(5, 7, 10, 0.98) 16%,
rgba(5, 7, 10, 0.88) 32%,
rgba(5, 7, 10, 0.66) 52%,
rgba(5, 7, 10, 0.38) 70%,
rgba(5, 7, 10, 0.15) 84%,
rgba(5, 7, 10, 0) 100%
);
}
.app-shell--bottom-fade.app-shell--bottom-fade-composer.app-shell--has-composer .app-shell-fade--bottom {
display: block;
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
height: calc(var(--composer-height, 0px) + 52px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 0) 0%,
rgba(5, 7, 10, 0.14) 18%,
rgba(5, 7, 10, 0.38) 38%,
rgba(5, 7, 10, 0.66) 58%,
rgba(5, 7, 10, 0.88) 76%,
rgba(5, 7, 10, 0.98) 90%,
rgba(5, 7, 10, 1) 100%
);
}
/* Иммерсивный edge-профиль использует тот же fade-layer, но прежнюю кривую графа. */
.app-shell--fade-edge.app-shell--top-fade.app-shell--has-topbar .app-shell-fade--top {
height: var(--topbar-height, 64px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 1) 0%,
rgba(5, 7, 10, 1) 32%,
rgba(5, 7, 10, 0.94) 38%,
rgba(5, 7, 10, 0.72) 46%,
rgba(5, 7, 10, 0.46) 54%,
rgba(5, 7, 10, 0.22) 61%,
rgba(5, 7, 10, 0.08) 65%,
rgba(5, 7, 10, 0) 68%,
rgba(5, 7, 10, 0) 100%
);
}
.app-shell--fade-edge.app-shell--bottom-fade.app-shell--bottom-fade-toolbar .app-shell-fade--bottom {
display: block;
bottom: 0;
height: var(--toolbar-height, 78px);
background: linear-gradient(
to bottom,
rgba(5, 7, 10, 0) 0%,
rgba(5, 7, 10, 0) 32%,
rgba(5, 7, 10, 0.08) 35%,
rgba(5, 7, 10, 0.22) 39%,
rgba(5, 7, 10, 0.46) 46%,
rgba(5, 7, 10, 0.72) 54%,
rgba(5, 7, 10, 0.94) 62%,
rgba(5, 7, 10, 1) 68%,
rgba(5, 7, 10, 1) 100%
);
}
/* При toolbar-anchored fade сам toolbar остаётся прозрачным над общим shell-layer. */
.app-shell--bottom-fade-toolbar .toolbar-slot {
isolation: isolate;
overflow: visible;
background: transparent;
border: 0;
box-shadow: none;
}
.app-shell--bottom-fade-toolbar .toolbar-slot > .toolbar {
position: relative;
z-index: 1;
background: transparent;
border: 0;
border-color: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Keyboard lifecycle остаётся частью App Shell, а не страницы чата. */
.app-shell.keyboard-open .toolbar-slot {
bottom: 0;
}
.app-shell--scroll-nested.keyboard-open .composer-slot {
bottom: var(--keyboard-offset, 0px);
padding-bottom: 0;
}
.app-shell--scroll-nested.keyboard-open .screen-content {
bottom: var(--keyboard-offset, 0px);
}
@media (min-width: 900px) {
.app-shell {
top: 16px;
height: calc(100svh - 32px);
border-radius: 24px;
}
}
+137 -336
View File
@@ -1,26 +1,26 @@
/*
* Единый визуальный язык кнопок основного приложения:
* белое содержимое, без рамок и самостоятельной подложки.
* Shared semantic button roles.
*
* Исключения:
* - фильтры групп на экране «Связи» (.fg-filter-chip) сохраняют прежний вид;
* - нижний toolbar (.toolbar-btn) полностью сохраняет исходное оформление.
* Stage 4: styling is opt-in by an existing UI role class. Component-owned
* controls (TopBar, Dropdown, Toolbar, tabs, ScrollToBottom, etc.) are styled
* by their own owner stylesheet and are intentionally absent from this file.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn),
:root a.primary-btn,
:root a.secondary-btn,
:root a.destructive-btn,
:root a.ghost-btn,
:root a.icon-btn,
:root a.text-btn {
color: #ffffff !important;
background: transparent !important;
background-image: none !important;
border: 0 !important;
box-shadow: none !important;
text-shadow: none !important;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
.ui-button,
.primary-btn,
.secondary-btn,
.destructive-btn,
.ghost-btn,
.icon-btn,
.text-btn,
.shine-btn {
color: #ffffff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
transition:
transform 90ms ease,
box-shadow 90ms ease,
@@ -28,350 +28,151 @@
filter 120ms ease;
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root a.primary-btn:hover,
:root a.secondary-btn:hover,
:root a.destructive-btn:hover,
:root a.ghost-btn:hover,
:root a.icon-btn:hover,
:root a.text-btn:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
.ui-button:hover,
.primary-btn:hover,
.secondary-btn:hover,
.destructive-btn:hover,
.ghost-btn:hover,
.icon-btn:hover,
.text-btn:hover,
.shine-btn:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
/* Короткий press-feedback: кнопка визуально уходит внутрь поверхности.
* Эффект существует только пока кнопка физически нажата.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root a.primary-btn:active,
:root a.secondary-btn:active,
:root a.destructive-btn:active,
:root a.ghost-btn:active,
:root a.icon-btn:active,
:root a.text-btn:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.12) !important;
border: 0 !important;
.primary-btn:hover,
.secondary-btn:hover,
.destructive-btn:hover,
.ghost-btn:hover,
.icon-btn:hover,
.text-btn:hover,
.shine-btn:hover {
transform: translateY(-1px);
}
.ui-button:active,
.primary-btn:active,
.secondary-btn:active,
.destructive-btn:active,
.ghost-btn:active,
.icon-btn:active,
.text-btn:active,
.shine-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
:root button:not(.fg-filter-chip):not(.toolbar-btn):disabled,
:root button:not(.fg-filter-chip):not(.toolbar-btn)[aria-disabled='true'],
:root a.primary-btn[aria-disabled='true'],
:root a.secondary-btn[aria-disabled='true'],
:root a.destructive-btn[aria-disabled='true'],
:root a.ghost-btn[aria-disabled='true'],
:root a.icon-btn[aria-disabled='true'],
:root a.text-btn[aria-disabled='true'] {
color: rgba(255, 255, 255, 0.42) !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
.ui-button:disabled,
.primary-btn:disabled,
.secondary-btn:disabled,
.destructive-btn:disabled,
.ghost-btn:disabled,
.icon-btn:disabled,
.text-btn:disabled,
.shine-btn:disabled,
.ui-button[aria-disabled='true'],
.primary-btn[aria-disabled='true'],
.secondary-btn[aria-disabled='true'],
.destructive-btn[aria-disabled='true'],
.ghost-btn[aria-disabled='true'],
.icon-btn[aria-disabled='true'],
.text-btn[aria-disabled='true'],
.shine-btn[aria-disabled='true'] {
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
}
/* Убираем декоративные стеклянные/неоновые подложки самих кнопок.
* Переключатель канала исключён: его ::after является функциональным бегунком.
* Toolbar исключён целиком: у него остаётся исходная графика приложения.
*/
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::before,
:root button:not(.fg-filter-chip):not(.toolbar-btn):not(.channel-toggle-btn)::after {
background: transparent !important;
background-image: none !important;
border-color: transparent !important;
box-shadow: none !important;
/* Decorative button pseudo-elements were not part of the Stage 3.1 surface. */
.ui-button::before,
.primary-btn::before,
.secondary-btn::before,
.destructive-btn::before,
.ghost-btn::before,
.icon-btn::before,
.text-btn::before,
.shine-btn::before,
.ui-button::after,
.primary-btn::after,
.secondary-btn::after,
.destructive-btn::after,
.ghost-btn::after,
.icon-btn::after,
.text-btn::after,
.shine-btn::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
/* Toolbar возвращён к исходному оформлению. Добавляем только краткое вдавливание
* на физическое нажатие; active-вкладка после отпускания остаётся такой, как была.
*/
:root .toolbar-btn {
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
color 120ms ease;
}
:root .toolbar-btn:active {
transform: translateY(1px) scale(0.96);
background: rgba(0, 0, 0, 0.14) !important;
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
}
/* Клавиатурный фокус остаётся различимым без постоянной рамки кнопки. */
:root button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root a.primary-btn:focus-visible,
:root a.secondary-btn:focus-visible,
:root a.destructive-btn:focus-visible,
:root a.ghost-btn:focus-visible,
:root a.icon-btn:focus-visible,
:root a.text-btn:focus-visible {
.ui-button:focus-visible,
.primary-btn:focus-visible,
.secondary-btn:focus-visible,
.destructive-btn:focus-visible,
.ghost-btn:focus-visible,
.icon-btn:focus-visible,
.text-btn:focus-visible,
.shine-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
/* Уведомления: «Ответы / События».
* ВАЖНО: общий button:hover выше имеет большую специфичность, поэтому для выбранной
* вкладки фиксируем отдельный data-selected и перечисляем hover/focus/active.
* Так выбранная кнопка остаётся визуально вдавленной и после отпускания мыши.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:hover,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:focus-visible,
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='true']:active {
color: #ffffff !important;
background: rgba(0, 0, 0, 0.18) !important;
border: 0 !important;
box-shadow:
inset 0 4px 11px rgba(0, 0, 0, 0.72),
inset 0 -1px 2px rgba(255, 255, 255, 0.10) !important;
transform: translateY(1px) scale(0.965) !important;
filter: brightness(0.88) !important;
}
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false'],
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:hover {
color: #ffffff !important;
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
transform: none !important;
filter: none !important;
}
/* Неактивная вкладка кратко вдавливается во время физического нажатия.
* После click data-selected меняется и постоянный стиль остаётся уже на ней.
*/
:root body .notifications-screen .tabs button.notification-tab-btn[data-selected='false']:active {
background: rgba(0, 0, 0, 0.12) !important;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08) !important;
transform: translateY(1px) scale(0.97) !important;
filter: brightness(0.9) !important;
}
/* Верхний toolbar отдельная цветовая роль: золотой текст и иконки.
* Это правило намеренно расположено после глобального белого button-rule. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: var(--app-topbar-gold) !important;
}
/* Верхний toolbar: вместо золотого акцента белые глифы с голубым ореолом.
* Правило стоит последним, чтобы перекрыть общий белый button-reset и старую золотую роль. */
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn),
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):active,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn),
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):hover,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):active {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56)) !important;
}
:root .topbar-slot button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible,
:root .network-header-overlay button:not(.fg-filter-chip):not(.toolbar-btn):focus-visible {
color: #FFFFFF !important;
outline: none !important;
filter:
drop-shadow(0 0 5px rgba(110, 205, 255, 0.82))
drop-shadow(0 0 10px rgba(72, 145, 255, 0.42)) !important;
}
/* Личный чат: нижние иконки используют ту же бело-голубую роль, что и верхний toolbar. */
:root .dm-chat-input button.dm-emoji-btn,
:root .dm-chat-input button.dm-send-btn,
:root .dm-chat-input button.dm-edit-banner__close,
:root .dm-chat-input button.dm-emoji-btn:hover,
:root .dm-chat-input button.dm-send-btn:hover,
:root .dm-chat-input button.dm-edit-banner__close:hover,
:root .dm-chat-input button.dm-emoji-btn:focus,
:root .dm-chat-input button.dm-send-btn:focus,
:root .dm-chat-input button.dm-edit-banner__close:focus {
color: #F7FBFF !important;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34) !important;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.46)) !important;
}
/* Второй общий тип кнопок. Цвет меняется одной переменной --shine-action-blue
* в main.css. Экран настройки сервера намеренно не получает этот класс. */
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn),
:root .screen-content.filled-action-buttons a.primary-btn,
:root .screen-content.filled-action-buttons a.secondary-btn,
:root .screen-content.filled-action-buttons a.ghost-btn,
:root .screen-content.filled-action-buttons a.text-btn {
color: #ffffff !important;
background: var(--shine-action-blue) !important;
background-image: linear-gradient(180deg, rgba(255,255,255,.14), rgba(255,255,255,0)) !important;
border: 0 !important;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.24),
0 8px 22px rgba(var(--shine-action-blue-rgb), .24) !important;
text-shadow: 0 1px 2px rgba(0,0,0,.18) !important;
/* Explicit filled-action variant used by pre-auth/settings/profile screens. */
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close) {
color: #ffffff;
background: var(--shine-action-blue);
background-image: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0));
border: 0;
border-radius: 14px;
}
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):hover,
:root .screen-content.filled-action-buttons a.primary-btn:hover,
:root .screen-content.filled-action-buttons a.secondary-btn:hover,
:root .screen-content.filled-action-buttons a.ghost-btn:hover,
:root .screen-content.filled-action-buttons a.text-btn:hover {
background: var(--shine-action-blue-hover) !important;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.28),
0 10px 26px rgba(var(--shine-action-blue-rgb), .32) !important;
inset 0 1px 0 rgba(255, 255, 255, 0.24),
0 8px 22px rgba(var(--shine-action-blue-rgb), 0.24);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);
}
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):active,
:root .screen-content.filled-action-buttons a.primary-btn:active,
:root .screen-content.filled-action-buttons a.secondary-btn:active,
:root .screen-content.filled-action-buttons a.ghost-btn:active,
:root .screen-content.filled-action-buttons a.text-btn:active {
background: var(--shine-action-blue-pressed) !important;
box-shadow: inset 0 3px 8px rgba(0,0,0,.26) !important;
}
:root .screen-content.filled-action-buttons button:not(.icon-btn):not(.shine-local-demo-btn):disabled {
color: rgba(255,255,255,.58) !important;
background: rgba(var(--shine-action-blue-rgb), .42) !important;
box-shadow: none !important;
}
/* Специальная системная кнопка должна сохранять синюю круглую подложку,
* несмотря на глобальный borderless-reset выше. */
:root button.scroll-to-bottom-btn,
:root button.scroll-to-bottom-btn:hover,
:root button.scroll-to-bottom-btn:focus,
:root button.scroll-to-bottom-btn:focus-visible {
color: #ffffff !important;
background: var(--shine-action-blue) !important;
border: 0 !important;
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):hover {
background: var(--shine-action-blue-hover);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.25),
0 10px 26px rgba(var(--shine-action-blue-rgb), .34) !important;
filter: none !important;
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 10px 26px rgba(var(--shine-action-blue-rgb), 0.32);
filter: brightness(1.12);
}
:root button.scroll-to-bottom-btn:hover {
background: var(--shine-action-blue-hover) !important;
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):active {
background: var(--shine-action-blue-pressed);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.26);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
:root button.scroll-to-bottom-btn:active {
background: var(--shine-action-blue-pressed) !important;
box-shadow: inset 0 3px 9px rgba(0,0,0,.28) !important;
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
/* Пункты унифицированных выпадающих меню остаются без постоянной заливки,
* но получают общую синюю реакцию при наведении/фокусе. */
:root button.dm-head-menu-item,
:root button.channel-menu-item,
:root button.dm-message-action-btn {
justify-content: flex-start !important;
color: #f4f8ff !important;
background: transparent !important;
text-align: left !important;
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::before,
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close)::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
:root button.dm-head-menu-item:hover,
:root button.dm-head-menu-item:focus-visible,
:root button.channel-menu-item:hover,
:root button.channel-menu-item:focus-visible,
:root button.dm-message-action-btn:hover,
:root button.dm-message-action-btn:focus-visible {
background: rgba(var(--shine-action-blue-rgb), .12) !important;
box-shadow: none !important;
transform: none !important;
}
:root button.channel-menu-item.destructive,
:root button.dm-message-action-btn--danger {
color: #ffb7c5 !important;
}
/* 2026-08-28: исключения из borderless-reset для явных выборов и действий настроек. */
:root body .language-choice-grid button.language-choice-option,
:root body .language-choice-grid button.language-choice-option:hover,
:root body .language-choice-grid button.language-choice-option:focus {
border: 1px solid rgba(210, 222, 241, 0.16) !important;
border-radius: 16px !important;
background: rgba(255, 255, 255, 0.035) !important;
box-shadow: none !important;
transform: none !important;
}
:root body .language-choice-grid button.language-choice-option.is-selected,
:root body .language-choice-grid button.language-choice-option.is-selected:hover,
:root body .language-choice-grid button.language-choice-option.is-selected:focus {
border-color: rgba(92, 190, 255, 0.62) !important;
background: rgba(39, 141, 255, 0.12) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10) !important;
}
:root body button.pairing-approve-btn,
:root body button.pairing-approve-btn:hover,
:root body button.pairing-approve-btn:focus {
background: linear-gradient(180deg, rgba(57, 180, 108, .92), rgba(22, 116, 69, .94)) !important;
border: 1px solid rgba(126, 235, 170, .55) !important;
border-radius: 14px !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,.18), 0 8px 22px rgba(22,116,69,.20) !important;
transform: none !important;
}
:root body button.pairing-reject-btn,
:root body button.pairing-reject-btn:hover,
:root body button.pairing-reject-btn:focus {
color: #fff1f3 !important;
background: rgba(134, 31, 49, .28) !important;
border: 1px solid rgba(255, 105, 128, .42) !important;
border-radius: 14px !important;
box-shadow: none !important;
}
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn) {
border: 1px solid rgba(183, 203, 235, 0.28) !important;
border-radius: 14px !important;
background:
linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%),
rgba(8, 19, 42, .58) !important;
box-shadow:
inset 0 1px 0 rgba(255,255,255,.10),
0 5px 16px rgba(0,0,0,.18) !important;
padding-inline: 14px;
transform: none !important;
}
:root body .screen-content.settings-bordered-actions button:not(.icon-btn):not(.key-toggle-btn):not(.channel-toggle-btn):not(.fg-filter-chip):not(.toolbar-btn):not(.pairing-approve-btn):not(.pairing-reject-btn):hover {
border-color: rgba(213, 225, 247, 0.42) !important;
background:
linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%),
rgba(10, 24, 52, .68) !important;
.filled-action-buttons :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .square-btn, .login-panel-inline-back, .registration-faq-link, .profiles-select, .profiles-close):disabled {
color: rgba(255, 255, 255, 0.58);
background: rgba(var(--shine-action-blue-rgb), 0.42);
box-shadow: none;
}
File diff suppressed because it is too large Load Diff
+310
View File
@@ -0,0 +1,310 @@
/* Shared attachment UI styles. */
.attachment-viewer-modal {
z-index: 80;
}
.attachment-viewer-card {
background: rgba(15, 23, 42, 0.96);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.2rem;
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
color: #fff;
display: grid;
gap: 0.8rem;
max-height: 92vh;
max-width: min(94vw, 68rem);
padding: 0.9rem;
width: 100%;
}
.attachment-viewer-head,
.attachment-viewer-actions {
align-items: center;
display: flex;
gap: 0.8rem;
justify-content: space-between;
}
.attachment-viewer-title {
font-weight: 800;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachment-viewer-body {
align-items: center;
display: grid;
justify-items: center;
min-height: 12rem;
}
.attachment-viewer-media {
background: #020617;
border-radius: 0.8rem;
max-height: 76vh;
max-width: 100%;
object-fit: contain;
}
.ar-attachment-manager-card {
max-width: min(92vw, 34rem);
}
.ar-attachment-manager-card--wide {
max-width: min(96vw, 58rem);
}
.ar-attachment-meta {
color: #cbd5e1;
display: grid;
font-size: 0.86rem;
gap: 0.25rem;
overflow-wrap: anywhere;
}
.ar-attachment-wallet-select,
.ar-attachment-wallet-select option {
color: #38bdf8;
}
.ar-attachment-history-table-wrap {
max-height: 55vh;
overflow: auto;
}
.ar-attachment-history-table {
border-collapse: collapse;
font-size: 0.84rem;
min-width: 48rem;
width: 100%;
}
.ar-attachment-history-table th,
.ar-attachment-history-table td {
border-bottom: 1px solid rgba(148, 163, 184, 0.25);
padding: 0.55rem;
text-align: left;
vertical-align: top;
}
.ar-attachment-history-table th {
color: #cbd5e1;
font-weight: 700;
}
.ar-attachment-history-tiles {
align-content: start;
box-sizing: border-box;
display: grid;
gap: 0.28rem;
grid-auto-rows: min-content;
max-height: 58vh;
max-width: 100%;
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 0.35rem;
scrollbar-color: rgba(212, 175, 55, 0.65) rgba(255, 255, 255, 0.06);
scrollbar-width: thin;
width: 100%;
}
.ar-attachment-history-tiles::-webkit-scrollbar {
display: block;
height: 4px;
width: 4px;
}
.ar-attachment-history-tiles::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.04);
}
.ar-attachment-history-tiles::-webkit-scrollbar-thumb {
background: rgba(212, 175, 55, 0.7);
border-radius: 999px;
}
.ar-attachment-history-tiles::-webkit-scrollbar-thumb:hover {
background: rgba(240, 198, 76, 0.9);
}
.ar-attachment-history-tile {
background: linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(22, 36, 53, 0.96));
border: 1px solid transparent;
border-radius: 0.52rem;
box-sizing: border-box;
color: #f8fafc;
display: grid;
gap: 0.12rem;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0.28rem 0.42rem;
width: 100%;
}
.ar-attachment-history-tile.is-selected {
border-color: rgba(34, 197, 94, 0.55);
box-shadow: inset 0 0 0 1px rgba(34, 197, 94, 0.18);
}
.ar-attachment-history-tile--page {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.18);
}
.ar-attachment-history-tile--with-preview {
gap: 0.32rem;
padding: 0.42rem;
}
.ar-attachment-history-preview {
align-items: flex-end;
background: rgba(255, 255, 255, 0.05);
border-radius: 0.42rem;
display: flex;
justify-content: space-between;
min-height: 7rem;
overflow: hidden;
position: relative;
}
.ar-attachment-history-preview-image {
display: block;
height: 100%;
object-fit: cover;
width: 100%;
}
.ar-attachment-history-preview-badge {
background: rgba(15, 23, 42, 0.72);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
bottom: 0.42rem;
color: #f8fafc;
font-size: 0.62rem;
left: 0.42rem;
padding: 0.2rem 0.46rem;
position: absolute;
}
.ar-attachment-history-tile-head {
align-items: flex-start;
display: flex;
gap: 0.65rem;
justify-content: space-between;
}
.ar-attachment-history-name {
display: block;
font-size: 0.78rem;
line-height: 1.08;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ar-attachment-history-tile-head strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ar-attachment-history-meta-row {
align-items: center;
color: #cbd5e1;
display: flex;
flex-wrap: wrap;
font-size: 0.6rem;
gap: 0.22rem;
line-height: 1.05;
min-width: 0;
}
.ar-attachment-history-txid {
background: rgba(255, 255, 255, 0.06);
border-radius: 0.34rem;
color: rgba(226, 232, 240, 0.86);
font-size: 0.56rem;
line-height: 1.05;
max-width: 100%;
min-width: 0;
overflow: hidden;
padding: 0.14rem 0.24rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.ar-attachment-status {
border-radius: 999px;
flex: 0 0 auto;
font-size: 0.58rem;
font-weight: 800;
line-height: 1;
padding: 0.2rem 0.32rem;
}
.ar-attachment-status--available {
background: rgba(220, 252, 231, 0.96);
color: #166534;
}
.ar-attachment-status--pending {
background: rgba(254, 249, 195, 0.96);
color: #854d0e;
}
.ar-attachment-status--unavailable {
background: rgba(254, 226, 226, 0.96);
color: #991b1b;
}
.ar-attachment-placement-flag {
background: rgba(219, 234, 254, 0.96);
border-radius: 999px;
color: #1e3a8a;
flex: 0 0 auto;
font-size: 0.58rem;
font-weight: 800;
line-height: 1;
padding: 0.2rem 0.32rem;
}
+271
View File
@@ -0,0 +1,271 @@
/* Shared avatar component styles. */
.avatar-preview-circle {
width: 124px;
height: 124px;
margin: 0 auto;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(155, 182, 233, 0.46);
background: rgba(13, 26, 50, 0.86);
box-shadow: inset 0 0 0 1px rgba(240, 248, 255, 0.1);
}
.avatar-preview-circle img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.avatar-wizard-preview {
width: 140px;
height: 140px;
}
.avatar-wizard-meta {
font-size: 13px;
line-height: 1.35;
color: #d9e7ff;
word-break: break-word;
}
.avatar-wizard-error {
min-height: 18px;
font-size: 13px;
color: #f6a8b3;
}
.avatar-wizard-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.avatar-wizard-choice-grid {
display: grid;
gap: 10px;
}
.avatar {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
flex: 0 0 auto;
aspect-ratio: 1 / 1;
border-radius: 50%;
background: linear-gradient(130deg, #3c4f73, #243352);
display: grid;
place-items: center;
font-weight: 700;
color: #e5ebff;
font-size: 14px;
}
.avatar.avatar-xs,
.avatar.xs {
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
font-size: 9px;
}
.avatar.avatar-sm,
.avatar.small {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
font-size: 10px;
}
.avatar.avatar-md,
.avatar.medium {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
font-size: 14px;
}
.avatar.avatar-lg,
.avatar.big {
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
font-size: 18px;
}
.avatar.avatar-xl,
.avatar.large,
.avatar.xlarge {
width: 96px;
height: 96px;
min-width: 96px;
min-height: 96px;
font-size: 26px;
}
.avatar-image {
position: relative;
overflow: hidden;
display: grid;
place-items: center;
}
.avatar-image > .avatar-fallback,
.avatar-image > img {
grid-area: 1 / 1;
}
.avatar-fallback {
width: 100%;
height: 100%;
display: grid;
place-items: center;
opacity: 1;
transition: opacity 120ms ease;
}
.avatar-image img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 50%;
display: block;
opacity: 0;
transition: opacity 120ms ease;
}
.avatar-image.has-image img {
opacity: 1;
}
.avatar-image.has-image .avatar-fallback {
opacity: 0;
visibility: hidden;
}
.avatar img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
/* ===== Единый шаблон аватаров (2026-08-22) =====
* Базовая аватарка повторяет принцип орбов экрана «Связи»:
* - fallback: нейтральный серый круг + белые инициалы;
* - фото занимает тот же внутренний круг;
* - стеклянный внешний круг остаётся поверх всегда, независимо от наличия фото;
* - усиленное свечение включается модификатором .avatar-glow, не меняя геометрию аватара.
*/
.avatar.avatar-image.avatar-framed {
position: relative;
overflow: visible;
isolation: isolate;
display: grid;
place-items: center;
border: 0;
background: transparent;
color: #ffffff;
box-shadow: none;
}
.avatar.avatar-image.avatar-framed > .avatar-fallback,
.avatar.avatar-image.avatar-framed > .avatar-photo {
grid-area: 1 / 1;
place-self: center;
width: 92.5%;
height: 92.5%;
border-radius: 50%;
z-index: 1;
}
.avatar.avatar-image.avatar-framed > .avatar-fallback {
display: grid;
place-items: center;
overflow: hidden;
background: #454b55;
color: #ffffff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.42);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -8px 16px rgba(0, 0, 0, 0.16);
}
.avatar.avatar-image.avatar-framed > .avatar-photo {
display: block;
object-fit: cover;
object-position: center;
opacity: 0;
transition: opacity 120ms ease;
}
.avatar.avatar-image.avatar-framed.has-image > .avatar-photo {
opacity: 1;
}
.avatar.avatar-image.avatar-framed.has-image > .avatar-fallback {
opacity: 0;
visibility: hidden;
}
/* Второй круг — тот же стеклянный overlay, который используется в «Связях». */
.avatar.avatar-image.avatar-framed::after {
content: "";
position: absolute;
inset: 50% auto auto 50%;
width: 119%;
height: 119%;
transform: translate(-50%, -50%);
border: 0;
border-radius: 0;
background: url("../assets/glass_overlay_faithful.png") center / contain no-repeat;
box-shadow: none;
opacity: 1;
pointer-events: none;
z-index: 3;
}
/* Опциональный усиленный ореол для состояний «сияющий» и будущих экранов. */
.avatar.avatar-image.avatar-framed.avatar-glow::before {
content: "";
position: absolute;
inset: -16%;
border-radius: 50%;
background: radial-gradient(circle, rgba(139, 232, 255, 0.36) 0%, rgba(116, 217, 255, 0.14) 48%, rgba(116, 217, 255, 0) 74%);
filter: blur(3px);
pointer-events: none;
z-index: -1;
}
+517
View File
@@ -0,0 +1,517 @@
/* Shared call UI styles. */
.call-overlay[hidden] {
display: none;
}
.call-ui-root[hidden] {
display: none;
}
.call-ui-root {
position: absolute;
inset: 0;
z-index: 80;
pointer-events: none;
}
.call-minimized-bar {
position: absolute;
top: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: calc(8px + env(safe-area-inset-top)) 14px 8px;
border-bottom: 1px solid rgba(159, 255, 196, 0.28);
background: linear-gradient(180deg, rgba(30, 138, 79, 0.98), rgba(18, 106, 60, 0.98));
box-shadow: 0 10px 24px rgba(5, 30, 16, 0.28);
pointer-events: auto;
}
.call-minimized-bar-text {
flex: 1 1 auto;
min-width: 0;
border: 0;
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
font-size: 13px;
font-weight: 700;
line-height: 1.2;
text-align: left;
padding: 0;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.call-minimized-bar-text:hover {
color: #ffffff;
filter: brightness(1.12);
}
.call-minimized-bar-text:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.call-minimized-bar-text:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.call-minimized-bar-text::before,
.call-minimized-bar-text::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.call-minimized-bar-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.call-icon-btn {
width: 40px;
min-width: 40px;
height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 12px;
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
cursor: pointer;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.call-icon-btn:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
transform: translateY(-1px);
filter: brightness(1.12);
}
.call-icon-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.call-icon-btn:disabled {
opacity: 1;
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
cursor: not-allowed;
transform: none;
filter: none;
}
.call-icon-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.call-icon-btn::before,
.call-icon-btn::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.call-icon-btn svg {
width: 20px;
height: 20px;
}
/* secondary/danger visual shells were never observable in Stage 3.1; only geometry variants remain. */
.call-icon-btn--bar {
width: 34px;
min-width: 34px;
height: 34px;
border-radius: 10px;
}
.call-icon-btn--bar svg {
width: 18px;
height: 18px;
}
.call-overlay {
position: absolute;
inset: 0;
background: rgba(4, 8, 16, 0.88);
backdrop-filter: blur(8px);
pointer-events: auto;
}
.call-overlay-panel {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(79, 124, 208, 0.24), transparent 34%),
linear-gradient(180deg, rgba(11, 20, 38, 0.98), rgba(3, 7, 15, 1));
}
.call-overlay-panel--remote-video {
background: #010203;
}
.call-overlay-head {
position: absolute;
top: calc(12px + env(safe-area-inset-top));
left: 14px;
right: 14px;
z-index: 4;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
pointer-events: none;
}
.call-overlay-title {
margin: 0;
min-height: 22px;
font-size: 14px;
font-weight: 700;
color: rgba(236, 242, 255, 0.94);
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.44);
}
.call-overlay-minimize-btn {
flex: 0 0 auto;
pointer-events: auto;
}
.call-overlay-status {
position: absolute;
left: 18px;
right: 18px;
bottom: calc(12px + env(safe-area-inset-bottom));
z-index: 4;
text-align: center;
color: rgba(207, 219, 246, 0.84);
font-size: 13px;
pointer-events: none;
}
.call-video-stage {
position: absolute;
inset: 0;
overflow: hidden;
background:
radial-gradient(circle at 50% 24%, rgba(54, 88, 151, 0.48), transparent 30%),
linear-gradient(180deg, rgba(9, 17, 32, 0.98), rgba(2, 5, 12, 1));
}
.call-video-stage--remote-video {
background: #000;
}
.call-remote-video,
.call-local-video {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
background: #02050a;
}
.call-remote-video[hidden],
.call-local-preview[hidden],
.call-hero[hidden] {
display: none !important;
}
.call-hero {
position: absolute;
left: 0;
right: 0;
top: clamp(8vh, 16vh, 19vh);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
padding: 0 24px;
text-align: center;
z-index: 2;
}
.call-peer-avatar-slot {
width: min(42vw, 168px);
height: min(42vw, 168px);
display: grid;
place-items: center;
}
.call-peer-avatar-slot .call-peer-avatar {
width: 100%;
height: 100%;
min-width: 100%;
min-height: 100%;
border-radius: 999px;
border: 2px solid rgba(221, 233, 255, 0.2);
background: linear-gradient(180deg, rgba(18, 31, 58, 0.96), rgba(9, 15, 29, 0.98));
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.34);
}
.call-peer-avatar-slot .call-peer-avatar .avatar-fallback {
font-size: clamp(34px, 9vw, 58px);
font-weight: 800;
}
.call-peer-login {
max-width: 86vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #f4f8ff;
font-size: clamp(20px, 5.8vw, 28px);
font-weight: 700;
letter-spacing: 0.02em;
}
.call-kind {
color: #ffffff;
font-size: clamp(27px, 8vw, 40px);
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.call-hero-status {
max-width: 88vw;
color: rgba(211, 223, 246, 0.9);
font-size: 14px;
line-height: 1.35;
}
.call-local-preview {
position: absolute;
right: 16px;
bottom: calc(18px + env(safe-area-inset-bottom));
z-index: 4;
width: min(30vw, 130px);
aspect-ratio: 3 / 4;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(176, 205, 255, 0.38);
background: rgba(7, 12, 22, 0.94);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
cursor: grab;
touch-action: none;
user-select: none;
}
.call-local-preview:active {
cursor: grabbing;
}
.call-overlay-controls {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: calc(env(safe-area-inset-bottom) + clamp(54px, 16vh, 112px));
display: flex;
flex-wrap: nowrap;
gap: 18px;
align-items: center;
justify-content: center;
width: min(92vw, 520px);
z-index: 4;
}
.call-overlay-controls[hidden],
.call-overlay-controls--incoming[hidden],
.call-overlay-controls--active[hidden],
.call-icon-btn[hidden],
.call-accept-btn[hidden],
.call-decline-btn[hidden],
.call-minimized-bar-actions[hidden] {
display: none !important;
}
.call-overlay-controls--incoming {
justify-content: center;
}
.call-overlay-controls--active {
justify-content: center;
}
.call-decline-btn {
min-width: 132px;
}
.call-accept-btn {
min-width: 132px;
min-height: 38px;
padding: 9px 12px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
font-weight: 700;
cursor: pointer;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.call-accept-btn:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
transform: translateY(-1px);
filter: brightness(1.12);
}
.call-accept-btn:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.call-accept-btn:disabled {
opacity: 1;
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
cursor: not-allowed;
transform: none;
filter: none;
}
.call-accept-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.call-accept-btn::before,
.call-accept-btn::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
@media (max-width: 420px) {
.call-overlay-controls {
gap: 16px;
bottom: calc(env(safe-area-inset-bottom) + clamp(48px, 14vh, 96px));
}
.call-icon-btn {
width: 46px;
min-width: 46px;
height: 46px;
border-radius: 14px;
}
.call-icon-btn svg {
width: 22px;
height: 22px;
}
.call-accept-btn,
.call-decline-btn {
min-width: 124px;
}
}
@@ -0,0 +1,145 @@
:root {
--z-overlay: 40;
--z-dropdown: 50;
--z-modal: 60;
}
.dropdown-portal {
pointer-events: none;
}
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: var(--z-overlay);
pointer-events: auto;
background: rgba(2, 6, 14, 0.34);
backdrop-filter: blur(1.5px);
-webkit-backdrop-filter: blur(1.5px);
}
.dropdown-menu {
position: fixed;
z-index: var(--z-dropdown);
display: grid;
gap: 2px;
padding: 6px;
min-width: 196px;
max-width: calc(100vw - 20px);
border: 1px solid rgba(92, 190, 255, 0.22);
border-radius: 16px;
background: linear-gradient(155deg, rgba(19, 27, 42, 0.97), rgba(7, 12, 22, 0.98));
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.48), 0 0 24px rgba(39, 141, 255, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.06);
backdrop-filter: blur(20px) saturate(125%);
-webkit-backdrop-filter: blur(20px) saturate(125%);
pointer-events: auto;
}
.dropdown-menu--transparent {
border-color: rgba(92, 190, 255, 0.06);
background: rgba(7, 12, 22, 0.44);
box-shadow: 0 18px 42px rgba(0, 0, 0, 0.30), inset 0 1px 0 rgba(255, 255, 255, 0.035);
}
.dropdown-menu__item {
width: 100%;
min-height: 42px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
border: 0;
border-radius: 10px;
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.dropdown-menu__item.is-selected {
background: rgba(39, 141, 255, 0.12);
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: none;
filter: none;
}
.dropdown-menu__item:focus-visible {
color: #ffffff;
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
background: rgba(39, 141, 255, 0.12);
box-shadow: none;
transform: none;
filter: none;
}
.dropdown-menu__item.is-danger { color: #ffb7c5; }
.dropdown-menu__item.is-danger:hover,
.dropdown-menu__item.is-danger:active { color: #ffffff; }
.dropdown-menu__item:disabled,
.dropdown-menu__item.is-danger:disabled {
opacity: 0.55;
color: rgba(255, 255, 255, 0.42);
background: transparent;
border: 0;
box-shadow: none;
transform: none;
filter: none;
cursor: default;
}
.dropdown-menu__item::before,
.dropdown-menu__item::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.dropdown-menu__icon,
.dropdown-menu__icon svg,
.dropdown-menu__item > img {
width: 18px;
height: 18px;
min-width: 18px;
flex: 0 0 18px;
object-fit: contain;
color: #65b7ff;
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
}
.dropdown-menu__divider { height: 1px; margin: 4px 8px; background: rgba(255, 255, 255, 0.08); }
@media (prefers-reduced-motion: reduce) { .dropdown-menu { animation: none; } }
+178
View File
@@ -0,0 +1,178 @@
/* Emoji picker and Twemoji UI styles. */
.emoji-picker-slot {
grid-column: 1 / -1;
}
.emoji-picker-slot[hidden] {
display: none;
}
.emoji-picker {
display: grid;
gap: 8px;
max-height: min(40vh, 292px);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
padding: 10px;
border-radius: 14px;
background: rgba(12, 19, 31, 0.98);
box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.2);
}
.emoji-picker::-webkit-scrollbar,
.emoji-picker-tabs::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.emoji-picker-section {
display: grid;
gap: 6px;
}
.emoji-picker-section-title {
color: rgba(205, 218, 246, 0.78);
font-size: 12px;
font-weight: 600;
}
.emoji-picker-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.emoji-picker-tab {
flex: 0 0 38px;
width: 38px;
}
.emoji-picker-tab,
.emoji-picker-item {
border: 0;
background: transparent;
background-image: none;
box-shadow: none;
color: #fff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-webkit-tap-highlight-color: transparent;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
.emoji-picker-tab {
min-height: 34px;
border-radius: 8px;
font-size: 19px;
}
.emoji-picker-grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 3px;
}
.emoji-picker-item {
min-height: 38px;
border-radius: 8px;
font-size: 24px;
line-height: 1;
}
.emoji-picker-tab:hover,
.emoji-picker-item:hover {
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
.emoji-picker-tab:focus,
.emoji-picker-item:focus {
outline: none;
box-shadow: none;
}
.emoji-picker-tab:active,
.emoji-picker-item:active {
color: #fff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.emoji-picker-tab:focus-visible,
.emoji-picker-item:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.emoji-picker-tab::before,
.emoji-picker-tab::after,
.emoji-picker-item::before,
.emoji-picker-item::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.twemoji,
.emoji-picker-preview {
display: inline-grid;
place-items: center;
width: 1em;
height: 1em;
vertical-align: -0.12em;
}
.twemoji-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.twemoji.is-error .twemoji-image,
.emoji-picker-preview.is-error .twemoji-image {
visibility: hidden;
}
+178
View File
@@ -0,0 +1,178 @@
/* Shared modal/toast/skeleton primitives. */
.modal-title {
font-size: 19px;
line-height: 1.2;
color: #f1d69a;
}
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 24;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(5, 9, 16, 0.74);
backdrop-filter: blur(4px);
}
.modal-dialog {
position: absolute;
left: 16px;
right: 16px;
bottom: 24px;
display: grid;
gap: 12px;
box-shadow: var(--shadow);
}
.modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
display: grid;
place-items: center;
padding: 20px;
z-index: 20;
}
.modal-card {
width: min(100%, 390px);
background: #172238;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 14px;
}
.modal-card {
background: linear-gradient(165deg, rgba(16, 31, 58, 0.97), rgba(10, 18, 36, 0.97));
border-color: transparent;
box-shadow: 0 20px 38px rgba(2, 6, 12, 0.55);
}
/* ===== Channels UX Stabilization ===== */
.toast-host {
position: fixed;
left: 0;
right: 0;
bottom: calc(18px + env(safe-area-inset-bottom));
display: grid;
justify-items: center;
gap: 8px;
z-index: 60;
pointer-events: none;
}
.toast {
min-width: min(88vw, 320px);
max-width: min(92vw, 420px);
border-radius: 14px;
padding: 11px 14px;
border: 1px solid transparent;
color: #f2dca8;
background: rgba(10, 14, 23, 0.8);
backdrop-filter: blur(12px);
box-shadow: 0 16px 30px rgba(1, 6, 12, 0.55);
opacity: 0;
transform: translateY(8px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.toast.is-visible {
opacity: 1;
transform: translateY(0);
}
.toast.is-hiding {
opacity: 0;
transform: translateY(8px);
}
.toast.toast--error {
color: #ffd8e0;
border-color: rgba(234, 122, 150, 0.45);
}
.skeleton-card {
display: grid;
gap: 8px;
}
.skeleton-line {
height: 10px;
border-radius: 999px;
background: linear-gradient(100deg, rgba(93, 117, 154, 0.10), rgba(167, 191, 226, 0.24), rgba(93, 117, 154, 0.10));
background-size: 220% 100%;
animation: channels-shimmer 1.2s linear infinite;
}
.skeleton-line.w-40 { width: 40%; }
.skeleton-line.w-70 { width: 70%; }
.skeleton-line.w-90 { width: 90%; }
#about-channel-modal.modal,
#edit-channel-modal.modal,
#reply-modal.modal,
#channel-message-modal.modal,
#channel-edit-description-modal.modal,
#channels-subscribe-modal.modal,
#thread-reply-modal.modal {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#about-channel-modal .modal-card,
#edit-channel-modal .modal-card,
#reply-modal .modal-card,
#channel-message-modal .modal-card,
#channel-edit-description-modal .modal-card,
#channels-subscribe-modal .modal-card,
#thread-reply-modal .modal-card {
background: rgba(15, 18, 30, 0.92);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid transparent;
border-radius: 20px;
padding: 24px;
}
#about-channel-modal .modal-title,
#edit-channel-modal .modal-title,
#reply-modal .modal-title,
#channel-message-modal .modal-title,
#channel-edit-description-modal .modal-title,
#channels-subscribe-modal .modal-title,
#thread-reply-modal .modal-title {
color: rgba(255, 200, 50, 0.95);
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
@@ -0,0 +1,28 @@
/* Shared overflow-dots trigger. */
/* ===== UI cleanup: main tabs, shared overflow menu and DM list (2026-08-22) ===== */
/* Единое вертикальное троеточие для всех overflow/menu-кнопок. */
.app-overflow-dots {
width: 8px;
height: 24px;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3.5px;
color: inherit;
pointer-events: none;
}
.app-overflow-dots i {
display: block;
width: 4px;
height: 4px;
flex: 0 0 4px;
border-radius: 50%;
background: currentColor;
box-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 10px rgba(72, 145, 255, 0.34);
}
+596
View File
@@ -0,0 +1,596 @@
/* Shared UI primitives retained from the former components.css. */
/* Глобально отключаем синюю tap-подсветку мобильных браузеров/WebView на ВСЕХ элементах
(Android/Chromium): синего квадрата при нажатии нигде быть не должно. */
* {
-webkit-tap-highlight-color: transparent;
}
.icon-btn,
.text-btn,
.primary-btn,
.secondary-btn,
.destructive-btn,
.ghost-btn {
border-radius: var(--radius-sm);
padding: 9px 12px;
min-height: 38px;
font-weight: 600;
cursor: pointer;
}
.icon-btn,
.text-btn,
.primary-btn,
.secondary-btn,
.destructive-btn,
.ghost-btn {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.icon-btn:disabled,
.text-btn:disabled,
.primary-btn:disabled,
.secondary-btn:disabled,
.destructive-btn:disabled,
.ghost-btn:disabled {
opacity: 1;
cursor: not-allowed;
}
.card {
background: linear-gradient(180deg, rgba(31, 44, 67, 0.62), rgba(21, 30, 48, 0.9));
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-lg);
padding: 14px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stack {
display: grid;
gap: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(132, 244, 161, 0.35);
color: #d7ffe3;
background: rgba(132, 244, 161, 0.09);
font-size: 12px;
font-weight: 600;
}
.badge.alt {
border-color: rgba(83, 216, 251, 0.35);
color: #dff8ff;
background: rgba(83, 216, 251, 0.11);
}
.badge.is-no {
border-color: rgba(170, 180, 205, 0.3);
color: #c5cedd;
background: rgba(152, 164, 190, 0.14);
}
.badge.is-yes-official {
border-color: rgba(132, 244, 161, 0.5);
color: #ddffe7;
background: rgba(132, 244, 161, 0.2);
}
.badge.is-yes-shine {
border-color: rgba(183, 122, 255, 0.6);
color: #f4e7ff;
background: rgba(176, 102, 255, 0.22);
}
.field-label {
color: #b2c2e6;
font-size: 13px;
}
.select {
width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
min-height: 44px;
padding: 0 12px;
color: var(--text);
}
.select:focus {
outline: none;
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
}
.wrap-row {
flex-wrap: wrap;
}
.status-line {
font-size: 13px;
color: var(--text-muted);
}
.status-line.is-available {
color: #8ef0a8;
}
.status-line.is-unavailable {
color: #ff8d97;
}
.help-fab,
.square-btn {
width: 42px;
height: 42px;
border-radius: 12px;
cursor: pointer;
}
.help-fab {
position: fixed;
right: max(20px, calc((100vw - min(100vw, 430px)) / 2 + 20px));
bottom: calc(20px + env(safe-area-inset-bottom));
z-index: 12;
}
.inline-input-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.link-card {
display: block;
padding: 14px;
border-radius: var(--radius-md);
background: rgba(83, 216, 251, 0.08);
border: 1px solid rgba(83, 216, 251, 0.22);
color: #d9f8ff;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 10px;
}
.key-card {
padding: 12px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.list-item {
display: grid;
grid-template-columns: 44px 1fr auto;
gap: 10px;
align-items: center;
padding: 11px;
border-radius: var(--radius-md);
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.meta-muted {
color: var(--text-muted);
font-size: 13px;
}
.unread {
min-width: 20px;
height: 20px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--accent);
color: #08212a;
font-size: 12px;
font-weight: 700;
padding: 0 6px;
}
.page-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-radius: 10px;
padding: 8px 10px;
color: #c5d2f4;
background: rgba(17, 24, 39, 0.9);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.page-label.is-collapsed {
width: fit-content;
padding: 6px;
}
.page-label-content {
min-width: 0;
}
.page-label-hint {
margin-bottom: 3px;
color: #8ea2cd;
font-size: 10px;
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.page-label-caption {
font-size: 12px;
line-height: 1.2;
}
.page-label-toggle {
width: 16px;
height: 16px;
flex: 0 0 16px;
border-radius: 4px;
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
cursor: pointer;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
.page-label-toggle::before,
.page-label-toggle::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.page-label-toggle:hover {
background: transparent;
filter: brightness(1.12);
}
.page-label-toggle:active {
background: rgba(0, 0, 0, 0.12);
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.page-label-toggle:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.chat-wrap {
display: grid;
grid-template-rows: 1fr auto;
gap: 10px;
min-height: calc(100dvh - 210px);
}
.messages-log {
display: grid;
gap: 8px;
align-content: start;
}
.chat-input {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.input {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
min-height: 40px;
padding: 0 12px;
width: 100%;
outline: none;
color: #f3f7ff;
font-size: 16px;
line-height: 1.4;
caret-color: #f1d18a;
-webkit-text-fill-color: #f3f7ff;
}
.input:focus {
border-color: rgba(83, 216, 251, 0.55);
box-shadow: 0 0 0 3px rgba(83, 216, 251, 0.12);
}
.input::placeholder {
color: rgba(190, 206, 238, 0.82);
}
input.input:-webkit-autofill,
input.input:-webkit-autofill:hover,
input.input:-webkit-autofill:focus,
textarea.input:-webkit-autofill,
textarea.input:-webkit-autofill:hover,
textarea.input:-webkit-autofill:focus {
-webkit-text-fill-color: #f3f7ff !important;
caret-color: #f1d18a;
border-color: var(--line);
box-shadow: 0 0 0 1000px rgba(11, 24, 46, 0.96) inset !important;
transition: background-color 9999s ease-out 0s;
}
textarea.input {
padding: 10px 12px;
min-height: 92px;
resize: vertical;
}
.inline-error {
min-height: 18px;
}
.form-actions-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mono-cell {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
overflow-wrap: anywhere;
}
.small-btn {
padding: 6px 10px;
font-size: 13px;
}
.key-value {
font-family: "IBM Plex Mono", "Fira Code", monospace;
font-size: 13px;
line-height: 1.35;
word-break: break-all;
color: #dce7ff;
}
.key-value--compact {
font-size: 11px;
line-height: 1.25;
}
.key-input {
font-family: "IBM Plex Mono", "Fira Code", monospace;
font-size: 12px;
letter-spacing: 0;
}
.key-toggle-btn {
width: 36px;
height: 36px;
min-height: 36px;
min-width: 36px;
padding: 0;
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 10px;
}
.key-toggle-icon {
width: 18px;
height: 18px;
display: block;
}
.boot-error-menu-shell {
z-index: 1000000;
}
.boot-error-menu-card {
gap: 10px;
}
.boot-error-menu-message {
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.4;
}
.node {
position: absolute;
transform: translate(-50%, -50%);
width: 126px;
border: 0;
background: transparent;
padding: 0;
text-align: center;
color: inherit;
cursor: pointer;
z-index: 1;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: #dbe7ff;
margin: 4px 2px;
}
.is-springing {
animation: spring-tap 0.28s ease;
}
#about-channel-modal .input,
#edit-channel-modal .input,
#reply-modal .input,
#channel-message-modal .input,
#channel-edit-description-modal .input,
#channels-subscribe-modal .input,
#thread-reply-modal .input {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: #ffffff;
padding: 12px 16px;
font-size: 14px;
resize: vertical;
width: 100%;
}
#channel-edit-description-counter,
#channel-edit-description-modal #channel-description-counter {
color: rgba(255, 255, 255, 0.35);
font-size: 12px;
}
#about-channel-modal .secondary-btn,
#edit-channel-modal .secondary-btn,
#reply-modal .secondary-btn,
#channel-message-modal .secondary-btn,
#channel-edit-description-modal .secondary-btn,
#channels-subscribe-modal .secondary-btn,
#thread-reply-modal .secondary-btn {
border-radius: 12px;
padding: 13px 24px;
}
#about-channel-modal .primary-btn,
#edit-channel-modal .primary-btn,
#reply-modal .primary-btn,
#channel-message-modal .primary-btn,
#channel-edit-description-modal .primary-btn,
#channels-subscribe-modal .primary-btn,
#thread-reply-modal .primary-btn {
border-radius: 12px;
padding: 13px 24px;
font-weight: 600;
}
/* Горизонтальный overflow: орб-ореол .dm-screen::before выходит на 12px по бокам (inset -12px) и
даёт лишний скролл. Фон НЕ меняем клиппим overflow на уровне страницы (как просит ТЗ, п.4). */
html,
body { overflow-x: hidden; }
/* ===== Final microinteractions: breathe cards + static energy buttons ===== */
@keyframes breatheCard {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-3px); }
}
@keyframes glareSweep {
0% { left: -150%; }
100% { left: 150%; }
}
@keyframes blurRevealMedium {
0% { filter: blur(0px); opacity: 1; color: inherit; }
40% { filter: blur(5px); opacity: 0; color: #D4AF37; }
100% { filter: blur(0px); opacity: 1; color: #D4AF37; }
}
/* 3) Medium blur-reveal for stats/counters */
.stat-blur-reveal {
animation: blurRevealMedium 1s cubic-bezier(0.4, 0, 0.2, 1) forwards;
display: inline-block;
}
/* Use the shared application canvas for every primary navigation screen. */
.dm-screen,
.profile-screen,
.notifications-screen {
background: transparent;
}
@@ -0,0 +1,86 @@
.scroll-to-bottom-btn__icon {
line-height: 1;
font-size: 28px;
font-weight: 700;
transform: translateY(-2px);
}
.scroll-to-bottom-btn {
position: absolute;
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px) + 18px + env(safe-area-inset-bottom));
margin: 0;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
border: 0;
background: transparent;
background-image: none;
color: #ffffff;
box-shadow: none;
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 38;
opacity: 0;
pointer-events: none;
transform: translateY(8px);
transition: opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
width: 54px;
height: 54px;
right: 16px;
border-radius: 50%;
}
/* Shared scroll-to-bottom control. */
/* ===== Общая кнопка прокрутки ленты вниз ===== */
.scroll-to-bottom-btn.is-visible {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.scroll-to-bottom-btn:hover {
background: transparent;
box-shadow: none;
filter: none;
}
.scroll-to-bottom-btn:active {
background: rgba(0, 0, 0, 0.12);
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: none;
}
.scroll-to-bottom-btn:focus,
.scroll-to-bottom-btn:focus-visible {
background: var(--shine-action-blue);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 10px 26px rgba(var(--shine-action-blue-rgb), 0.34);
}
.scroll-to-bottom-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.scroll-to-bottom-btn::before,
.scroll-to-bottom-btn::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.app-shell--scroll-nested.keyboard-open .scroll-to-bottom-btn {
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px) + 18px);
}
/* Общая круглая кнопка прокрутки. Её цвет задаётся --shine-action-blue. */
+54
View File
@@ -0,0 +1,54 @@
/* Shared tab geometry and tab primitives. */
.tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 4px;
background: rgba(255, 255, 255, 0.02);
}
.tab-btn {
border: 0;
background: transparent;
border-radius: 9px;
padding: 8px;
color: var(--text-muted);
cursor: pointer;
}
.tab-btn.active {
background: rgba(83, 216, 251, 0.16);
color: var(--text);
}
/* ===== Shared primary topbar + upper tabs geometry (2026-08-22) =====
* «Связи» теперь монтируют header в общий topbar-slot, как остальные основные экраны.
* Верхние переключатели используют один ритм и не меняют вертикальную позицию между страницами.
*/
:root {
--app-primary-tabs-top-gap: 14px;
--app-primary-tabs-gap: 8px;
--app-primary-tab-min-height: 28px;
}
/* Общий контейнер верхних кнопок-вкладок. */
.app-top-tabs {
min-height: var(--app-primary-tab-min-height);
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: var(--app-primary-tabs-gap);
margin: 0;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
+316
View File
@@ -0,0 +1,316 @@
/* Bottom toolbar component styles. */
.toolbar-item,
.toolbar-action,
.toolbar-button {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
/* Базовая поверхность toolbar: финальные effective declarations Этапа 3.1. */
.toolbar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
min-height: 82px;
padding: 9px 9px 6px;
align-items: end;
overflow: visible;
background: transparent;
border: 0;
border-radius: 20px;
box-shadow: 0 18px 32px rgba(2, 6, 13, 0.62);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.toolbar-btn {
min-height: 52px;
padding: 4px 3px 2px;
display: grid;
align-content: end;
justify-items: center;
gap: 2px;
border: 0;
border-radius: 12px;
background: transparent;
color: #b8c7ea;
font-size: 11px;
cursor: pointer;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
color 120ms ease;
}
.toolbar-btn.active {
background: transparent;
border: 1px solid transparent;
box-shadow: none;
color: #D4AF37;
}
.toolbar-btn.active span:first-child {
color: #D4AF37;
filter: drop-shadow(0 0 10px rgba(212, 175, 55, 0.6));
}
.toolbar-btn.active span:last-child {
color: #D4AF37;
text-shadow: 0 0 10px rgba(212, 175, 55, 0.6);
}
.toolbar-btn:active {
transform: translateY(1px) scale(0.96);
background: rgba(0, 0, 0, 0.14);
box-shadow:
inset 0 4px 10px rgba(0, 0, 0, 0.62),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
}
.toolbar-btn-network {
position: relative;
width: calc(100% + 6px);
min-height: 72px;
margin-inline: -3px;
padding-bottom: 2px;
align-content: end;
overflow: visible;
-webkit-tap-highlight-color: transparent;
}
.toolbar-label-wrap {
display: grid;
justify-items: center;
line-height: 1.05;
}
.toolbar-btn-profile {
position: relative;
}
.toolbar-btn-messages {
position: relative;
}
.toolbar-unread-badge {
position: absolute;
top: 2px;
right: 6px;
min-width: 16px;
height: 16px;
border-radius: 999px;
padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #f07f8a;
color: #fff2f4;
border: 1px solid rgba(255, 222, 227, 0.55);
box-shadow: 0 4px 10px rgba(152, 36, 52, 0.35);
font-size: 10px;
font-weight: 700;
line-height: 1;
pointer-events: none;
}
.toolbar-btn-profile .toolbar-label-wrap {
padding-bottom: 0;
}
.toolbar-btn-profile .toolbar-connection-indicator {
display: none;
}
.toolbar-connection-indicator {
position: absolute;
left: 50%;
bottom: 0;
transform: translateX(-50%);
display: inline-flex;
align-items: center;
gap: 3px;
min-height: 9px;
}
.toolbar-connection-text {
display: none;
font-size: 8px;
letter-spacing: 0.02em;
text-transform: lowercase;
color: rgba(191, 213, 255, 0.8);
opacity: 0;
transition: opacity 0.2s ease;
}
.toolbar-connection-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(148, 162, 195, 0.65);
box-shadow: 0 0 0 2px rgba(97, 116, 156, 0.25);
transition: 0.2s ease;
}
.toolbar-connection-indicator.is-connected .toolbar-connection-text {
opacity: 1;
color: rgba(145, 255, 192, 0.9);
}
.toolbar-connection-indicator.is-connected .toolbar-connection-dot {
background: #71e9a5;
box-shadow: 0 0 0 2px rgba(72, 201, 134, 0.28);
}
.toolbar-connection-indicator.is-connecting .toolbar-connection-dot {
background: #f0c56b;
}
.toolbar-connection-indicator.is-disconnected .toolbar-connection-dot {
background: #e48792;
box-shadow: 0 0 0 2px rgba(228, 135, 146, 0.24);
}
/* Неоновые PNG-иконки вкладок (свечение запечено в PNG). Цвет доп.свечения — var --tab-glow (инлайн на img). */
.toolbar-icon-img {
--tab-icon-size: 27px; /* крупнее (бар-иконки); герой ниже ещё больше */
width: var(--tab-icon-size);
height: var(--tab-icon-size);
object-fit: contain;
display: block;
transition: transform .12s ease, filter .15s ease;
}
/* Активная вкладка — лёгкое доп. свечение (подпись подсвечивается правилом .active span:last-child выше). */
.toolbar-btn.active .toolbar-icon-img {
filter: drop-shadow(0 0 5px var(--tab-glow)) brightness(1.08);
}
/* Нажатие — вдавливание + краткая вспышка свечения; на отпускании возврат. */
.toolbar-btn:active .toolbar-icon-img {
transform: scale(0.9);
filter: drop-shadow(0 0 9px var(--tab-glow)) brightness(1.2);
}
/* «Связи» — герой: крупнее и всегда чуть светится сильнее остальных; press-feedback ярче. */
.toolbar-btn-hero .toolbar-icon-img {
/* Крупнее ВИЗУАЛЬНО через transform (origin center) раскладочный размер как у остальных (27px),
поэтому иконка остаётся на одной линии с другими, а не задирается вверх. */
transform: scale(1.63); /* ≈44px при базовых 27px */
filter: brightness(1.05); /* CSS-ореол убран — светится только сама PNG (логотип не тронут) */
}
.toolbar-btn-hero.active .toolbar-icon-img {
filter: brightness(1.12);
}
.toolbar-btn-hero:active .toolbar-icon-img {
transform: scale(1.47); /* 1.63 × 0.9 (нажатие) */
filter: brightness(1.25); /* нажатие — только подсветление, без ореола */
}
.toolbar-channels-hold-overlay {
position: fixed;
z-index: 1200;
transform: translate(-50%, -100%);
display: grid;
grid-template-columns: repeat(3, minmax(68px, 1fr));
gap: 6px;
padding: 8px;
border-radius: 10px;
background: rgba(15, 18, 31, 0.94);
border: 1px solid rgba(160, 175, 220, 0.35);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35);
}
.toolbar-channels-hold-item {
border: 1px solid rgba(160, 175, 220, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #e9efff;
font-size: 12px;
line-height: 1.2;
min-height: 34px;
padding: 6px 8px;
transition: background-color 0.12s ease, border-color 0.12s ease;
}
.toolbar-channels-hold-item.is-active {
background: rgba(133, 170, 255, 0.34);
border-color: rgba(197, 219, 255, 0.75);
}
/* Cosmic styling for the "Связи" toolbar button */
/* нет рамки/подсветки фокуса ВОКРУГ кнопки — светится только сама иконка (её drop-shadow) */
.toolbar-btn-network:focus,
.toolbar-btn-network:focus-visible {
outline: none;
}
.toolbar-btn-network::before {
content: "";
display: none; /* подсветка-подложка вокруг иконки «Связи» убрана по запросу (иконка и её drop-shadow-ореол не тронуты) */
position: absolute;
inset: 6px;
border-radius: 10px;
pointer-events: none;
opacity: 0.42;
background:
radial-gradient(circle at 24% 24%, rgba(112, 170, 255, 0.35), transparent 56%),
radial-gradient(circle at 78% 72%, rgba(197, 132, 255, 0.24), transparent 60%);
filter: blur(8px);
}
.toolbar-btn-network span:first-child {
color: #9eb3e8;
filter: drop-shadow(0 0 5px rgba(123, 170, 255, 0.24));
}
/* ===== Единая полировка меню, DM и управляющих кнопок (2026-08-26) ===== */
/* Центральная вкладка остаётся на прежней оси иконок, но имеет большую
* интерактивную область; подпись опущена ниже и больше не заходит на логотип. */
.toolbar-btn-network > span:last-child {
display: block;
position: relative;
transform: translateY(-2px);
line-height: 1.05;
padding-bottom: 1px;
}
/* Toolbar: reserve enough vertical room for the Connections label and safe area. */
:root { --toolbar-height: 88px; }
.toolbar-slot {
min-height: calc(88px + env(safe-area-inset-bottom));
overflow: visible;
padding-bottom: env(safe-area-inset-bottom);
}
.toolbar-btn-network .toolbar-icon-img {
transform: translateY(-4px);
}
+222
View File
@@ -0,0 +1,222 @@
:root {
--app-topbar-base-height: 64px;
--app-topbar-title-size: 18px;
--app-topbar-bg: #05070A;
--app-topbar-fg: #F7FBFF;
--app-topbar-gold: var(--app-topbar-fg);
--app-topbar-blue-glow: rgba(92, 190, 255, 0.72);
--app-topbar-blue-glow-soft: rgba(72, 145, 255, 0.34);
}
.topbar {
width: 100%;
min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
margin: 0 0 14px;
padding: calc(10px + env(safe-area-inset-top)) 0 10px;
box-sizing: border-box;
display: grid;
grid-template-columns: minmax(44px, 1fr) minmax(0, auto) minmax(44px, 1fr);
align-items: center;
gap: 8px;
}
.topbar-slot .topbar {
height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
min-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
max-height: calc(var(--app-topbar-base-height) + env(safe-area-inset-top));
margin: 0;
padding-top: env(safe-area-inset-top);
padding-bottom: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.topbar__left,
.topbar__center,
.topbar__right {
min-width: 0;
display: flex;
align-items: center;
}
.topbar__left { width: 100%; justify-content: flex-start; gap: 8px; }
.topbar__center { justify-content: center; overflow: hidden; text-align: center; }
.topbar__right { width: 100%; justify-content: flex-end; gap: 8px; }
.topbar__title {
margin: 0;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-main);
font-size: var(--app-topbar-title-size);
font-weight: 700;
line-height: 22px;
letter-spacing: 0;
color: var(--app-topbar-fg);
text-align: center;
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 14px var(--app-topbar-blue-glow-soft);
}
.topbar__left-label {
display: inline-block;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #a8bcdf;
}
.topbar__back {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-main);
font-size: 24px;
line-height: 1;
font-weight: 500;
}
.topbar-slot .topbar__action,
.topbar-slot .topbar__back {
min-height: 40px;
height: 40px;
color: var(--app-topbar-fg);
text-shadow: 0 0 5px var(--app-topbar-blue-glow), 0 0 12px var(--app-topbar-blue-glow-soft);
}
.topbar-slot .topbar__action svg,
.topbar-slot .topbar__back svg {
filter: drop-shadow(0 0 3px var(--app-topbar-blue-glow)) drop-shadow(0 0 7px var(--app-topbar-blue-glow-soft));
}
.topbar--profile .topbar__left { visibility: hidden; }
@media (prefers-reduced-motion: reduce) {
.topbar__action,
.topbar__back { transition: none; }
}
/* Custom center content used by chat/channel/list screens still follows the same TopBar rhythm. */
.topbar-slot {
padding: 0 12px;
background: var(--app-topbar-bg);
}
.topbar__center .app-topbar-title-action,
.topbar__center .channels-top-title,
.topbar__center .dm-head-filter-title {
margin: 0;
min-width: 0;
max-width: 100%;
min-height: 40px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-main);
font-size: var(--app-topbar-title-size);
font-weight: 700;
line-height: 22px;
text-align: center;
}
.topbar__center .app-topbar-title-action::before,
.topbar__center .app-topbar-title-action::after,
.topbar__center .channels-top-title::before,
.topbar__center .channels-top-title::after,
.topbar__center .dm-head-filter-title::before,
.topbar__center .dm-head-filter-title::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.topbar-slot .topbar__center button,
.topbar-slot .topbar__center button:hover,
.topbar-slot .topbar__center button:focus,
.topbar-slot .topbar__center button:active,
.topbar-slot .topbar__center button:focus-visible {
color: #f7fbff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
text-shadow:
0 0 5px rgba(92, 190, 255, 0.72),
0 0 12px rgba(72, 145, 255, 0.34);
backdrop-filter: none;
-webkit-backdrop-filter: none;
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56));
}
.topbar-slot .topbar__center button::before,
.topbar-slot .topbar__center button::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
/* TopBar owns its button interaction states. */
.topbar-slot .topbar__action,
.topbar-slot .topbar__back {
filter: drop-shadow(0 0 4px rgba(92, 190, 255, 0.56));
}
.topbar-slot .topbar__action:active,
.topbar-slot .topbar__back:active,
.topbar-slot .topbar__center button:active {
background: rgba(0, 0, 0, 0.12);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
}
.topbar-slot .topbar__action:focus-visible,
.topbar-slot .topbar__back:focus-visible,
.topbar-slot .topbar__center button:focus-visible {
color: #ffffff;
outline: none;
filter: drop-shadow(0 0 5px rgba(110, 205, 255, 0.82)) drop-shadow(0 0 10px rgba(72, 145, 255, 0.42));
}
.topbar-slot .topbar__action[aria-expanded='true'],
.topbar-slot .topbar__action[data-open='true'],
.topbar-slot .topbar__center button[aria-expanded='true'],
.topbar-slot .topbar__center button[data-open='true'] {
background: rgba(0, 0, 0, 0.16);
box-shadow: inset 0 3px 8px rgba(0, 0, 0, 0.58), inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
/* Migrated from legacy components.css during Stage 3 ownership split. */
.header-icon-svg {
display: block;
width: 1em;
height: 1em;
}
.header-icon-svg--phone,
.header-icon-svg--search {
width: 22px;
height: 22px;
}
+247
View File
@@ -0,0 +1,247 @@
/* Channel thread screen styles. */
.thread-node-card.is-rating {
border-color: transparent;
background:
linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)),
rgba(20, 25, 35, 0.55);
box-shadow: 0 0 38px rgba(181, 136, 42, 0.14);
}
.thread-node-stats.is-hidden {
opacity: 0;
max-height: 0;
overflow: hidden;
margin: 0;
}
.thread-node-heading {
color: #f1dcab;
font-size: 15px;
}
.thread-node-meta {
color: #aebddd;
font-size: 12px;
}
.thread-node-body {
color: #eef3ff;
font-size: 14px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
border-radius: 10px;
padding: 8px 10px;
background: linear-gradient(170deg, rgba(22, 40, 73, 0.72), rgba(12, 25, 48, 0.78));
border: 1px solid rgba(116, 141, 193, 0.24);
}
.thread-node-stats {
color: #99acd6;
font-size: 12px;
}
.thread-node-views {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.58);
}
.thread-node-actions {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.channels-screen--thread .thread-node-actions {
display: flex;
grid-template-columns: none;
}
.thread-node-level {
--depth: 0;
margin-left: calc(var(--depth) * 12px);
}
.channels-screen--thread .thread-node-card {
padding: 14px;
margin-bottom: 10px;
}
.thread-history-divider {
height: 0;
border-top: 2px solid rgba(255, 255, 255, 0.26);
margin: 6px 0 10px;
}
.thread-block--ancestors > .section-title {
color: #b9cbef;
}
.thread-block--ancestors {
border-color: rgba(141, 166, 214, 0.26);
background: linear-gradient(160deg, rgba(12, 28, 56, 0.73), rgba(7, 15, 32, 0.78));
}
.thread-block--focus > .section-title {
color: #f0d9a4;
}
.thread-block--focus {
border-color: transparent;
background: linear-gradient(160deg, rgba(33, 44, 72, 0.68), rgba(12, 20, 36, 0.8));
}
.thread-block--replies > .section-title {
color: #c8d6f5;
}
.thread-block--replies {
border-color: rgba(161, 186, 233, 0.24);
}
.thread-like-btn {
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
}
.thread-like-btn.is-long-press {
transform: scale(0.96);
filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28));
box-shadow: none;
}
.thread-open-btn {
color: rgba(255, 255, 255, 0.62);
}
@media (max-width: 430px) {
.thread-node-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 365px) {
.thread-node-actions {
grid-template-columns: 1fr;
}
}
.thread-node-card.is-own-new {
box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46);
}
@media (max-width: 420px) {
.thread-node-actions {
grid-template-columns: 1fr;
}
}
.thread-block {
gap: 8px;
padding: 8px;
background: rgba(20, 25, 35, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
border-color: rgba(255, 255, 255, 0.07);
}
.thread-summary {
color: #efd9a4;
background: rgba(20, 25, 35, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
border-color: rgba(255, 255, 255, 0.07);
}
.thread-node-card {
gap: 9px;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
padding: 20px;
margin-bottom: 16px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(20, 25, 35, 0.55);
}
.thread-node-actions .secondary-btn {
display: inline-flex;
align-items: center;
gap: 5px;
background: none;
border: none;
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
cursor: pointer;
padding: 0;
min-height: 0;
white-space: nowrap;
}
.channels-screen--thread .channels-user-chip {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
margin-bottom: 0;
color: rgba(255, 255, 255, 0.35);
min-height: 0;
font-size: 12px;
}
.thread-block--replies > .section-title {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-weight: 500;
margin: 0 0 2px;
}
.channels-screen .thread-node-card,
.channels-screen .thread-block,
.channels-screen .thread-summary {
animation: breatheCard 8s ease-in-out infinite;
position: relative;
}
.channels-screen .thread-node-card:hover,
.channels-screen .thread-block:hover,
.channels-screen .thread-summary:hover {
transform: none;
}
/* Thread cards should stay fixed without breathing motion */
.channels-screen--thread .thread-node-card,
.channels-screen--thread .thread-block,
.channels-screen--thread .thread-summary {
animation: none;
}
+685
View File
@@ -0,0 +1,685 @@
/* Channel screen styles. */
.channels-screen.channels-screen--channel::before {
background:
radial-gradient(circle at 14% 16%, rgba(82, 73, 184, 0.2), transparent 44%),
radial-gradient(circle at 86% 8%, rgba(57, 89, 161, 0.22), transparent 46%),
radial-gradient(circle at 54% 84%, rgba(64, 47, 138, 0.15), transparent 42%),
linear-gradient(180deg, #0a0b10 0%, #0a0b10 62%, rgba(10, 11, 16, 0.92) 100%);
}
.channel-counter-meta,
.channel-counter-value {
opacity: 0;
transform: translateY(3px);
transition: opacity 0.22s ease, transform 0.22s ease;
}
.channel-head-card {
display: grid;
gap: 6px;
}
.channel-head-actions {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.channel-note {
font-size: 13px;
color: #e8d8b0;
line-height: 1.4;
}
.channel-feed {
gap: 10px;
}
.channel-unread-line {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0 12px;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid transparent;
background:
linear-gradient(180deg, rgba(50, 39, 14, 0.9), rgba(22, 25, 39, 0.9));
color: #ffe6a7;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
line-height: 1;
text-transform: uppercase;
text-align: center;
justify-content: center;
box-shadow: 0 10px 24px rgba(6, 10, 20, 0.35);
}
.channel-unread-line::before,
.channel-unread-line::after {
content: "";
flex: 1 1 0;
height: 1px;
min-width: 18px;
background: linear-gradient(90deg, transparent, rgba(244, 202, 102, 0.8), transparent);
}
.channels-screen--channel .channel-feed {
gap: 2px;
margin-left: -7px;
margin-right: -7px;
}
.channel-message-top {
display: flex;
align-items: center;
gap: 12px;
}
.channel-message-avatar.avatar-image {
display: grid;
place-items: center;
}
.channel-message-kind-badge--status {
color: #bfe9d1;
background: rgba(38, 92, 62, 0.32);
border: 1px solid rgba(137, 223, 176, 0.26);
}
.channel-message-target-preview {
display: grid;
gap: 4px;
padding: 9px 11px;
border-radius: 10px;
border: 1px solid rgba(154, 178, 224, 0.16);
background: rgba(13, 22, 39, 0.56);
}
.channel-message-target-preview strong {
color: #f0d99c;
font-size: 12px;
}
.channel-message-target-preview span {
color: rgba(188, 208, 244, 0.82);
font-size: 12px;
}
.channel-message-target-preview p {
margin: 0;
color: #d9e6ff;
font-size: 13px;
line-height: 1.35;
}
.channels-screen .channel-message-card.is-rating {
border-color: transparent;
background:
linear-gradient(180deg, rgba(112, 84, 22, 0.12), rgba(20, 25, 35, 0.58)),
rgba(20, 25, 35, 0.55);
box-shadow: 0 0 38px rgba(181, 136, 42, 0.14);
}
.channels-screen .channel-message-card.is-focus-flash {
border-color: transparent;
box-shadow: 0 0 34px rgba(255, 214, 117, 0.16);
}
.channel-message-body--deleted {
color: #ff9e9e;
border: 1px solid rgba(255, 126, 126, 0.5);
background: rgba(120, 18, 18, 0.28);
border-radius: 10px;
padding: 8px 10px;
}
.channel-system-event-card {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: fit-content;
max-width: min(88%, 460px);
margin: 0 auto 4px;
padding: 8px 16px;
border-color: transparent;
border-radius: 999px;
background: rgba(89, 67, 31, 0.26);
color: rgba(255, 235, 188, 0.92);
cursor: pointer;
text-align: center;
}
.channel-system-event-card__label {
display: block;
font-size: 13px;
line-height: 1.2;
}
.channel-meta-details-grid {
display: grid;
grid-template-columns: minmax(96px, 120px) 1fr;
gap: 10px 14px;
align-items: start;
color: rgba(255, 255, 255, 0.92);
}
.channel-meta-details-grid > span:nth-child(odd) {
color: rgba(255, 255, 255, 0.48);
}
.channel-meta-details-grid code {
color: rgba(255, 214, 117, 0.92);
word-break: break-word;
}
.channel-profile-card {
align-items: stretch;
}
.channel-profile-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.channel-profile-edit-btn {
width: 38px;
padding-left: 0;
padding-right: 0;
}
.channel-profile-avatar {
--channel-avatar-size: 96px;
width: var(--channel-avatar-size);
height: var(--channel-avatar-size);
display: inline-flex;
align-items: center;
justify-content: center;
align-self: center;
border-radius: 50%;
background: linear-gradient(145deg, rgba(255, 214, 122, 0.22), rgba(56, 41, 22, 0.65));
border: 1px solid transparent;
color: rgba(255, 236, 194, 0.95);
font-size: calc(var(--channel-avatar-size) * 0.42);
font-weight: 700;
overflow: hidden;
}
.channel-profile-avatar img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.channel-message-views {
margin: 0;
font-size: 12px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.58);
}
.channel-message-stats.is-hidden {
opacity: 0;
max-height: 0;
overflow: hidden;
margin: 0;
}
.channels-screen .channel-message-card.is-counters-visible .channel-action-counter {
opacity: 1;
max-width: 40px;
margin-left: 2px;
transform: translateX(0);
}
.topbar__right .channel-header-entrypoint-btn {
border: 1px solid transparent;
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
color: #f1d99c;
border-radius: 10px;
padding-inline: 9px;
font-size: 11px;
font-weight: 700;
letter-spacing: .02em;
text-transform: uppercase;
}
.channel-main-action {
min-height: 48px;
border-radius: 13px;
}
.channel-back-btn {
min-height: 44px;
}
.channel-action-like {
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
}
.channel-action-like.is-long-press {
transform: scale(0.96);
filter: brightness(1.08) drop-shadow(0 0 8px rgba(255, 220, 100, 0.28));
box-shadow: none;
}
.channel-menu-item:disabled {
opacity: 0.68;
cursor: not-allowed;
}
.channel-toggle-btn {
width: 48px;
height: 28px;
border-radius: 999px;
border: 0;
background: transparent;
color: #fff;
box-shadow: none;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
position: relative;
cursor: pointer;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
.channel-toggle-btn:hover {
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
.channel-toggle-btn:active {
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.channel-toggle-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.channel-toggle-btn::after {
content: "";
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #d9e6ff;
transition: transform 0.24s cubic-bezier(.2,.9,.3,1.15), background 0.2s ease;
}
.channel-toggle-btn.is-on::after {
transform: translateX(20px);
background: #f4dca6;
}
.channel-head-description {
margin: 0;
color: #c8d7f6;
font-size: 14px;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.channel-head-description.is-expanded {
-webkit-line-clamp: unset;
display: block;
}
.channel-head-more {
border: 0;
background: transparent;
color: #efcf8b;
font-size: 12px;
padding: 0;
width: fit-content;
cursor: pointer;
}
.channel-message-type-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 4px 9px;
border-radius: 9px;
border: 1px solid transparent;
background: linear-gradient(180deg, rgba(57, 74, 119, 0.34), rgba(18, 29, 54, 0.34));
color: #f1d99c;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.channel-message-tools {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.channel-message-type-select {
min-width: 0;
min-height: 46px;
}
.channels-screen .channel-message-card.is-own-new {
box-shadow: 0 0 52px rgba(88, 69, 176, 0.2), 0 12px 24px rgba(2, 8, 16, 0.46);
}
.primary-btn.channel-main-action {
border-radius: 14px;
padding: 14px;
width: 100%;
}
.channel-head-card {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
margin-bottom: 6px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.channel-head-title {
line-height: 1.35;
font-family: inherit;
color: rgba(255, 255, 255, 0.95);
font-size: 22px;
font-weight: 700;
text-align: center;
}
.channel-head-meta {
line-height: 1.35;
font-family: inherit;
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-weight: 500;
text-align: center;
}
.entrypoint-history-list {
display: grid;
gap: 10px;
max-height: min(58vh, 30rem);
overflow: auto;
}
.entrypoint-history-item {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
appearance: none;
border-radius: 14px;
display: grid;
gap: 4px;
padding: 12px 13px;
text-align: left;
}
.entrypoint-history-item strong {
color: #f1d99c;
font-size: 13px;
}
.entrypoint-history-item span {
color: rgba(188, 208, 244, 0.74);
font-size: 12px;
}
.entrypoint-history-item p {
margin: 0;
color: #f5f8ff;
font-size: 13px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.channel-head-actions .secondary-btn {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.6);
border-radius: 12px;
padding: 10px 16px;
}
/* 1) Minimal breathing only for content cards */
.channels-screen .channel-message-card {
gap: 14px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.07);
backdrop-filter: blur(24px);
box-shadow: 0 0 60px rgba(80, 60, 180, 0.15);
padding: 20px;
margin-bottom: 16px;
border-color: rgba(255, 255, 255, 0.07);
background: rgba(20, 25, 35, 0.55);
animation: breatheCard 8s ease-in-out infinite;
position: relative;
}
/* Keep card hover static so breathe is the only motion */
.channels-screen .channel-message-card:hover {
transform: none;
}
.channels-screen--channel .channel-main-action--compose {
position: sticky;
bottom: -12px;
margin: 16px 20px 0;
width: calc(100% - 40px);
font-weight: 700;
z-index: 3;
}
.channel-menu-trigger .app-overflow-dots {
margin: auto;
}
.channel-menu-trigger {
width: 34px;
height: 34px;
border-radius: 10px;
cursor: pointer;
font-size: 18px;
line-height: 1;
border: none;
background: none;
color: var(--app-topbar-gold);
text-shadow: 0 0 5px var(--app-topbar-blue-glow),
0 0 12px var(--app-topbar-blue-glow-soft);
}
/* У меню больше нет «облачного» треугольного хвостика. */
.channel-menu-item {
width: 100%;
min-height: 42px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
border: 0;
border-radius: 10px;
background: transparent;
background-image: none;
box-shadow: none;
color: #ffffff;
font: inherit;
font-size: 14px;
font-weight: 600;
line-height: 1.2;
text-align: left;
white-space: nowrap;
cursor: pointer;
}
.channel-menu-item:hover {
color: #ffffff;
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
.channel-menu-item:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.channel-menu-item:focus-visible {
color: #ffffff;
background: rgba(var(--shine-action-blue-rgb), 0.12);
border: 0;
box-shadow: none;
transform: none;
filter: none;
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.channel-menu-item::before,
.channel-menu-item::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.channel-menu-icon {
width: 18px;
height: 18px;
min-width: 18px;
flex: 0 0 18px;
object-fit: contain;
color: #65b7ff;
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
}
.channel-menu-item.destructive {
color: #ffb7c5;
background: transparent;
}
.channel-menu-item.destructive:hover,
.channel-menu-item.destructive:active {
color: #ffffff;
}
.channel-menu-item.destructive:active {
background: rgba(0, 0, 0, 0.12);
}
.channel-menu-item.destructive:focus-visible {
color: #ffb7c5;
background: rgba(var(--shine-action-blue-rgb), 0.12);
}
@@ -0,0 +1,438 @@
/* Styles shared by Channel and Thread rendering. */
.attachment-trigger-btn {
justify-self: start;
min-width: 3.25rem;
padding-inline: 0.8rem;
}
.draft-attachments {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.draft-attachment-chip {
border-radius: 999px;
cursor: pointer;
font-size: 0.82rem;
max-width: 100%;
overflow: hidden;
padding: 0.4rem 0.65rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-danger-action {
width: 100%;
}
.channels-screen--channel .channel-message-card {
margin-bottom: 6px;
}
.channels-screen--channel .channel-message-card.is-diary-entry {
width: min(100%, 28rem);
max-width: min(84vw, 28rem);
padding: 16px 16px 15px;
justify-self: center;
align-self: center;
}
.channel-message-author-tile {
appearance: none;
-webkit-appearance: none;
display: flex;
align-items: center;
gap: 12px;
flex: 1 1 auto;
width: auto;
min-width: 0;
padding: 8px 10px;
border-radius: 8px;
text-align: left;
cursor: pointer;
min-height: 0;
}
.channel-message-avatar {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: #f4f6ff;
background: radial-gradient(circle at 30% 30%, #8a73ff, #4f4bda 58%, #3b2b89);
}
.channel-message-author {
display: grid;
gap: 4px;
min-width: 0;
}
.channel-message-head-row {
display: flex;
align-items: stretch;
gap: 8px;
}
.channel-message-title {
font-size: 15px;
color: #f5f8ff;
}
.channel-message-kind-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
padding: 3px 8px;
border-radius: 999px;
font-size: 12px;
line-height: 1.2;
}
.channel-message-kind-badge--rating {
color: #ffe8b0;
background: rgba(124, 92, 28, 0.36);
border: 1px solid transparent;
}
.channel-message-body {
color: #ffffff;
line-height: 1.5;
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
margin: 0;
padding: 0;
background: transparent;
border: 0;
}
.channel-message-card--deleted-compact {
gap: 7px;
padding-top: 9px;
padding-bottom: 9px;
}
.deleted-message-pill {
appearance: none;
width: 100%;
border-radius: 12px;
font-size: 13px;
line-height: 1.2;
text-align: left;
padding: 8px 10px;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.channel-message-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
}
.channel-message-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.channel-message-actions::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.channel-action-item {
appearance: none;
border: 0;
background: transparent;
position: relative;
min-height: 0;
padding: 0;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 24px;
font-size: 12px;
cursor: pointer;
transition: color 0.18s ease, text-shadow 0.18s ease, transform 0.18s ease;
white-space: nowrap;
}
.channel-action-item:disabled {
opacity: 0.56;
cursor: not-allowed;
}
.channel-action-icon {
font-size: 14px;
line-height: 1;
}
.channel-action-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.message-edited-marker {
appearance: none;
border: none;
background: transparent;
font-size: 11px;
line-height: 1;
padding: 0;
margin-left: 6px;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.channel-action-counter {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
opacity: 0;
max-width: 0;
overflow: hidden;
transform: translateX(-3px);
transition: opacity 0.2s ease, max-width 0.2s ease, transform 0.2s ease, margin-left 0.2s ease;
}
.blockchain-details-card {
max-width: min(620px, calc(100vw - 28px));
}
.blockchain-details-grid {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 8px 12px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
}
.blockchain-details-grid span {
color: rgba(255, 255, 255, 0.58);
font-size: 12px;
}
.blockchain-details-grid code,
.blockchain-details-grid strong {
min-width: 0;
color: rgba(255, 244, 210, 0.96);
font-size: 12px;
overflow-wrap: anywhere;
}
.blockchain-raw-block {
max-height: 280px;
overflow: auto;
padding: 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.32);
color: rgba(255, 255, 255, 0.82);
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.channel-header-route-btn {
position: static;
min-height: 40px;
max-width: min(72vw, 100%);
padding: 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
border-radius: 10px;
text-align: center;
transform: none;
}
.channel-header-route-btn:hover,
.channel-header-route-btn:focus-visible,
.channel-header-route-btn:active,
.channel-header-route-btn.is-springing {
transform: none;
}
@media (max-width: 430px) {
.channel-message-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 365px) {
.channel-message-actions {
grid-template-columns: 1fr;
}
}
.author-line {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
width: 100%;
}
.author-line-main {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.channel-message-type-button {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 82px;
min-height: 28px;
padding: 6px 10px;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer;
}
.channel-message-type-button.is-static,
.channel-message-type-button:disabled {
opacity: 1;
cursor: default;
}
.author-line-login {
font-weight: 700;
color: #f5f8ff;
}
.author-line-num {
font-weight: 400;
color: rgba(255, 255, 255, 0.44);
}
@media (max-width: 420px) {
.channel-message-actions {
grid-template-columns: 1fr;
}
}
.channel-message-actions,
.thread-node-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: nowrap;
overflow-x: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
/* 2) Static controls with energy + glass glare (no levitation) */
.channels-screen--list .channels-tab-btn,
.channels-screen--list .channels-bottom-action,
.channels-screen .channel-main-action,
.channels-screen .channel-back-btn,
.channels-screen .channel-head-actions .secondary-btn,
.channels-screen .channel-action-item {
position: relative;
overflow: hidden;
transition: box-shadow 0.75s ease-out, color 0.28s ease, border-color 0.28s ease, background 0.28s ease;
animation: none;
}
.channels-screen--list .channels-tab-btn:hover,
.channels-screen--list .channels-bottom-action:hover,
.channels-screen .channel-main-action:hover,
.channels-screen .channel-back-btn:hover,
.channels-screen .channel-head-actions .secondary-btn:hover,
.channels-screen .channel-action-item:hover {
transform: none;
}
.channels-screen::before,
.channels-screen.channels-screen--channel::before {
background: transparent;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,202 @@
/* Diagnostics/admin/developer UI styles. */
.key-storage-option {
gap: 6px;
}
.key-storage-option__description {
margin: 0;
}
.key-storage-note {
margin: 0;
line-height: 1.45;
}
.key-storage-note--strong {
font-size: 17px;
font-weight: 700;
color: #eef4ff;
}
.pwa-diag-list {
gap: 8px;
}
.pwa-diag-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 10px;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(143, 167, 215, 0.18);
background: rgba(14, 24, 45, 0.42);
}
.pwa-diag-key {
color: #cdd9f2;
font-size: 13px;
}
.pwa-diag-value {
color: #edf3ff;
font-size: 13px;
text-align: right;
word-break: break-word;
}
.pwa-diag-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
background: #64708a;
}
.pwa-diag-indicator.is-ok {
background: #66d69a;
box-shadow: 0 0 0 3px rgba(102, 214, 154, 0.18);
}
.pwa-diag-indicator.is-warn {
background: #f0c56b;
}
.pwa-diag-indicator.is-bad {
background: #e48792;
box-shadow: 0 0 0 3px rgba(228, 135, 146, 0.18);
}
.pwa-diag-indicator.is-neutral {
background: #8994ab;
}
.pwa-diag-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.pwa-diag-recommendations {
gap: 6px;
}
.pwa-diag-json {
margin: 0;
max-height: 260px;
overflow: auto;
border-radius: 10px;
padding: 10px;
font-size: 12px;
line-height: 1.35;
color: #dfe9ff;
background: rgba(8, 15, 28, 0.82);
border: 1px solid rgba(134, 157, 205, 0.2);
}
.arweave-uploads-screen {
box-sizing: border-box;
min-height: 100vh;
max-width: 100%;
overflow-x: hidden;
padding-top: 3.25rem;
}
.arweave-uploads-toolbar {
align-items: center;
background: linear-gradient(135deg, rgba(15, 23, 42, 0.98), rgba(30, 41, 59, 0.98));
border-bottom: 1px solid transparent;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
box-sizing: border-box;
display: grid;
gap: 0.42rem;
grid-template-columns: 2.4rem minmax(0, 1fr) 2.4rem 2.4rem;
justify-content: stretch;
left: 0;
margin: 0;
padding: max(0.35rem, env(safe-area-inset-top)) 0.45rem 0.45rem;
position: fixed;
right: 0;
top: 0;
z-index: 15;
}
.arweave-uploads-back,
.arweave-uploads-add,
.arweave-uploads-menu-btn {
align-items: center;
aspect-ratio: 1;
display: inline-flex;
font-size: 1.2rem;
height: 2.35rem;
justify-content: center;
min-width: 0;
padding: 0;
}
.arweave-uploads-title {
align-self: center;
color: #f8fafc;
font-size: 0.94rem;
font-weight: 800;
min-width: 0;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.arweave-uploads-menu {
background: rgba(15, 23, 42, 0.98);
border: 1px solid transparent;
border-radius: 0.85rem;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
display: grid;
min-width: 13rem;
padding: 0.35rem;
position: absolute;
right: 0.45rem;
top: calc(100% + 0.35rem);
z-index: 20;
}
.arweave-uploads-menu[hidden] {
display: none;
}
.arweave-uploads-menu .text-btn {
color: #f8fafc;
text-align: left;
}
.arweave-uploads-list {
max-height: calc(100vh - 4.1rem);
min-height: calc(100vh - 4.1rem);
width: 100%;
}
.arweave-uploads-menu-btn .app-overflow-dots {
margin: auto;
}
+325
View File
@@ -0,0 +1,325 @@
/* Device/session/pairing feature styles. */
.qr-card {
justify-items: center;
}
.qr-code {
width: min(220px, 100%);
aspect-ratio: 1;
fill: #eff5ff;
background: #111723;
border-radius: 22px;
padding: 18px;
}
.camera-shell {
position: relative;
min-height: 380px;
border-radius: var(--radius-lg);
overflow: hidden;
background: #09101a;
}
.camera-video {
width: 100%;
height: 100%;
min-height: 380px;
object-fit: cover;
display: block;
}
.camera-frame {
position: absolute;
inset: 70px 40px 110px;
border: 3px solid rgba(83, 216, 251, 0.85);
border-radius: 24px;
box-shadow: 0 0 0 999px rgba(5, 9, 16, 0.38);
}
.camera-hint,
.camera-error {
position: absolute;
left: 16px;
right: 16px;
text-align: center;
padding: 10px 12px;
border-radius: 12px;
background: rgba(10, 14, 23, 0.78);
}
.camera-hint {
bottom: 18px;
}
.camera-error {
top: 18px;
color: #ffd7df;
}
.camera-placeholder {
width: 100%;
min-height: 380px;
display: grid;
place-items: center;
color: #c8d6f9;
background:
radial-gradient(circle at 20% 10%, rgba(83, 216, 251, 0.16), transparent 48%),
linear-gradient(180deg, #0a1220, #070d17);
}
.session-status {
color: var(--text-muted);
font-size: 13px;
}
.session-status--online {
color: #3dbb72;
}
.session-item {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-md);
padding: 10px;
cursor: pointer;
}
.session-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.session-tab {
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
min-height: 36px;
padding: 6px 8px;
cursor: pointer;
}
.session-tab.is-active {
color: var(--text);
border-color: rgba(83, 216, 251, 0.45);
background: rgba(83, 216, 251, 0.15);
}
.session-current-badge {
display: inline-flex;
margin-top: 8px;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
color: #d7ffe3;
border: 1px solid rgba(132, 244, 161, 0.36);
background: rgba(132, 244, 161, 0.1);
}
.qr-demo {
width: 64px;
height: 64px;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.85);
background:
linear-gradient(
90deg,
#f6fbff 0 8px,
#0f1524 8px 16px,
#f6fbff 16px 24px,
#0f1524 24px 32px,
#f6fbff 32px 40px,
#0f1524 40px 48px,
#f6fbff 48px 56px,
#0f1524 56px 64px
);
}
.qr-image {
width: 64px;
height: 64px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: #fff;
}
/* Pairing approve/reject use their semantic button roles; legacy green/red surfaces were not observable in Stage 3.1. */
.pairing-request-actions > button {
flex: 1 1 140px;
}
.pairing-transfer-dialog {
position: fixed;
inset: 0;
z-index: 13000;
}
.pairing-transfer-dialog__backdrop {
position: absolute;
inset: 0;
background: rgba(3, 7, 15, .76);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.pairing-transfer-dialog__card {
position: absolute;
left: 50%;
top: 50%;
width: min(calc(100vw - 28px), 390px);
transform: translate(-50%, -50%);
padding: 16px;
gap: 14px;
}
.pairing-transfer-key-list {
display: grid;
gap: 9px;
}
.pairing-transfer-key {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 11px;
min-height: 58px;
padding: 9px 12px;
border: 1px solid rgba(255,255,255,.11);
border-radius: 14px;
background: rgba(255,255,255,.035);
}
.pairing-transfer-key span {
display: grid;
gap: 2px;
}
.pairing-transfer-key small {
color: rgba(255,255,255,.48);
}
.pairing-transfer-key.is-required {
opacity: .82;
}
.pairing-transfer-dialog__actions {
justify-content: flex-end;
flex-wrap: wrap;
}
/* Pairing actions retain the exact Stage 3.1 focus-only approve/reject variant. */
.screen-content.settings-bordered-actions .pairing-approve-btn,
.screen-content.settings-bordered-actions .pairing-reject-btn {
border-radius: 14px;
padding-inline: 12px;
}
.screen-content.settings-bordered-actions .pairing-approve-btn {
color: #fff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
transform: none;
}
.screen-content.settings-bordered-actions .pairing-approve-btn:hover {
color: #fff;
background: transparent;
background-image: none;
border: 0;
box-shadow: none;
transform: none;
filter: brightness(1.12);
}
.screen-content.settings-bordered-actions .pairing-approve-btn:active {
color: #fff;
background: rgba(0, 0, 0, 0.12);
background-image: none;
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: none;
filter: brightness(0.9);
}
.screen-content.settings-bordered-actions .pairing-approve-btn:focus,
.screen-content.settings-bordered-actions .pairing-approve-btn:focus-visible {
background: linear-gradient(180deg, rgba(57, 180, 108, 0.92), rgba(22, 116, 69, 0.94));
border: 1px solid rgba(126, 235, 170, 0.55);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 8px 22px rgba(22, 116, 69, 0.20);
transform: none;
filter: none;
}
.screen-content.settings-bordered-actions .pairing-reject-btn {
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
}
.screen-content.settings-bordered-actions .pairing-reject-btn:hover {
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
transform: translateY(-1px);
filter: brightness(1.12);
}
.screen-content.settings-bordered-actions .pairing-reject-btn:active {
color: #fff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.screen-content.settings-bordered-actions .pairing-reject-btn:focus,
.screen-content.settings-bordered-actions .pairing-reject-btn:focus-visible {
color: #fff1f3;
background: rgba(134, 31, 49, 0.28);
border: 1px solid rgba(255, 105, 128, 0.42);
box-shadow: none;
transform: none;
filter: none;
}
+724
View File
@@ -0,0 +1,724 @@
/* Поиск контактов относится к feature «Личные/Контакты». */
.contact-search-actions {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.contact-search-form-card,
.contact-search-results-card {
margin: 0 6px;
padding: 14px;
border-radius: 24px;
background: rgba(7, 10, 18, 0.88);
border: 1px solid rgba(140, 99, 255, 0.24);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.34);
}
.contact-search-input {
border-radius: 16px;
}
.contact-search-results-title {
font-size: 14px;
font-weight: 700;
color: rgba(252, 234, 192, 0.92);
text-align: center;
}
.contact-search-result-main {
min-width: 0;
display: flex;
align-items: center;
}
/* Personal messages list and DM-list styles. */
/* ===== Direct Messages Glass Theme (DM-only) ===== */
.dm-screen {
position: relative;
isolation: isolate;
color: rgba(255, 255, 255, 0.9);
min-height: 100%;
align-content: start;
}
.screen-content .dm-screen {
min-height: 100%;
}
@keyframes dm-orbs-drift {
from { transform: translate3d(0, 0, 0) scale(1); }
to { transform: translate3d(0, -8px, 0) scale(1.02); }
}
.dm-dialog-card {
position: relative;
display: grid;
grid-template-columns: 60px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
min-height: 74px;
padding: 10px 12px 10px 10px;
border-radius: 26px;
background: rgba(7, 10, 18, 0.88);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(140, 99, 255, 0.32); /* оконтовка = цвет линии связи; default = violet (контакт) */
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.42);
cursor: pointer;
}
.dm-dialog-card:focus-visible { outline: 2px solid var(--rel-link); outline-offset: 2px; }
.dm-card--family { border-color: transparent; }
/* линия связи: gold (семья) */
.dm-card--shining { border-color: rgba(104, 216, 255, 0.45); }
/* линия связи: cyan (сияющий) */
.dm-screen .list-item .avatar {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
}
.dm-screen .list-item {
align-items: stretch;
}
.dm-screen .meta-muted {
color: rgba(255, 255, 255, 0.5);
}
/* ===== «Личные сообщения» v2 — списочная форма «Связей» ===== */
/* Токены-мост: НЕ придумываем цвета, наследуем канонический язык «Связей» (network-graph.css :root). */
.dm-screen {
--dm-tone-default: var(--rel-contact);
--dm-tone-family: var(--rel-family);
--dm-tone-shining: var(--rel-shining);
}
/* DM-шапка через grid 1fr auto 1fr: бренд слева, title строго по центру, «+» справа. */
.dm-head-brand { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dm-head-logo-wrap { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; flex: 0 0 auto; }
.dm-head-logo {
width: 36px;
height: 36px;
object-fit: contain;
display: block;
filter: drop-shadow(0 0 7px rgba(71, 196, 255, 0.38));
}
/* Центр шапки — светящийся бренд «Shine» */
@keyframes dm-shine-pulse {
0%, 100% { text-shadow: 0 0 5px rgba(240, 184, 46, 0.42), 0 0 12px rgba(240, 184, 46, 0.26), 0 0 22px rgba(240, 184, 46, 0.12); }
50% { text-shadow: 0 0 9px rgba(240, 184, 46, 0.68), 0 0 20px rgba(240, 184, 46, 0.46), 0 0 34px rgba(240, 184, 46, 0.26); }
}
.dm-divider { position: relative; height: 18px; margin: 6px 14px 8px; }
.dm-divider::before { content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 1px; background: linear-gradient(90deg, transparent, rgba(240, 184, 46, 0.5), transparent); }
.dm-divider::after { content: ""; position: absolute; left: 50%; top: 50%; width: 6px; height: 6px; transform: translate(-50%, -50%) rotate(45deg); background: var(--rel-family); box-shadow: 0 0 8px var(--rel-family-glow); }
/* список: скролл внутри контента, карточки не ужимаем, отступ снизу под bottom nav (86px) + 16px */
.dm-list { display: flex; flex-direction: column; gap: 8px; padding: 0 6px; padding-bottom: calc(86px + 16px); }
/* текст карточки */
.dm-row-main { min-width: 0; }
.dm-row-titleline { display: flex; align-items: center; gap: 6px; min-width: 0; }
.dm-row-titlewrap { flex-wrap: wrap; row-gap: 6px; }
.dm-dialog-card .dm-row-title { font-size: 16px; font-weight: 600; color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-dialog-card .dm-row-last-message { font-size: 14px; color: rgba(244, 246, 255, 0.48); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dm-contact-note {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(244, 246, 255, 0.62);
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
/* галочка-подтверждён у имени (золотая, без слова «Подтверждён») */
.dm-name-check { display: inline-flex; flex: 0 0 auto; color: var(--rel-family); }
.dm-name-check svg { width: 16px; height: 16px; }
/* AvatarRing — обод/свечение по тону (адаптация орбов «Связей», без тяжёлой магии) */
.dm-av { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; position: relative; }
.dm-av .avatar { width: 40px; height: 40px; min-width: 40px; min-height: 40px; border: none; box-shadow: none; }
/* Цветные обводки вокруг аватаров (violet/gold) убраны по просьбе. Свечение оставляем только у сияющих (ниже). */
.dm-av--default { box-shadow: none; }
.dm-av--family { box-shadow: none; }
/* Сияющий аватар = АДАПТАЦИЯ сияющего узла экрана «Связи»: та же небесная палитра, тот же небесный rim,
тот же двойной «дышащий» пульс. Переиспользуем ОБЩИЕ keyframes графа (fg-shine-glow пульс box-shadow,
fg-shine-halo дыхание радиального ореола; объявлены в network-graph.css, грузится глобально), а не рисуем
второй похожий эффект. Радиальный ореол повторяет стопы узла графа; SVG-фильтр #fg-shine-glow есть только на
стр. «Связи», поэтому здесь мягкий CSS-blur. Мини-сфера компактная не размывает текст/соседей. */
.dm-av--shining {
border: 1px solid rgba(150, 240, 255, 0.62);
animation: fg-shine-glow 3.6s ease-in-out infinite;
}
.dm-av--shining::before {
content: ""; position: absolute; inset: -12px; border-radius: 50%; z-index: -1; pointer-events: none;
background: radial-gradient(circle, rgba(140, 240, 255, 0.5) 0%, rgba(130, 235, 255, 0.18) 46%, rgba(130, 235, 255, 0) 72%);
filter: blur(3.4px); /* = stdDeviation 3.4 SVG-фильтра #fg-shine-glow графа; геометрия inset −12px тоже как у узла (58px↔56px, scale≈1) */
animation: fg-shine-halo 3.6s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.dm-av--shining { animation: none; }
.dm-av--shining::before { animation: none; }
}
.dm-row-meta-col {
display: inline-flex;
min-width: 0;
align-self: stretch;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
gap: 4px;
}
.dm-row-meta-line {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
}
.dm-row-meta-spacer {
width: 24px;
height: 24px;
}
.dm-row-time {
font-size: 12px;
font-weight: 600;
color: rgba(244, 246, 255, 0.44);
}
.dm-row-time--empty {
width: 0;
min-width: 0;
overflow: hidden;
}
/* непрочитанные — отдельная violet-сфера (НЕ изумруд) */
.dm-unread-badge {
min-width: 24px; height: 24px; padding: 0 7px; border-radius: 12px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700; color: var(--text);
background: rgba(140, 99, 255, 0.16); border: 1px solid rgba(140, 99, 255, 0.55);
}
.dm-chevron { display: inline-flex; color: rgba(244, 246, 255, 0.32); }
.dm-chevron svg { width: 16px; height: 16px; }
@media (max-width: 380px) {
.dm-dialog-card {
grid-template-columns: 60px minmax(0, 1fr);
row-gap: 10px;
}
.dm-row-meta-col {
grid-column: 2;
justify-self: end;
}
}
/* Значок «связь через кого» — ТОЛЬКО иконка (кликабельная); детали пути в попапе ниже */
.dm-via {
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
width: 24px; height: 24px; padding: 0; border-radius: 8px; cursor: pointer;
color: var(--rel-link); border: 1px solid rgba(25, 229, 138, 0.5); background: rgba(25, 229, 138, 0.08);
}
.dm-via-icon { display: inline-flex; }
.dm-via-icon svg { width: 14px; height: 14px; }
/* попап пути связи: Ты → …посредники… → он; узлы = аватар+имя, кликабельные → профиль */
.dm-via-path {
display: none; position: absolute; left: 14px; right: 14px; top: 46px; z-index: 6;
flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 11px; border-radius: 12px;
background: rgba(8, 12, 20, 0.97); border: 1px solid rgba(25, 229, 138, 0.35);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.55);
}
.dm-via-path.is-open { display: flex; }
.dm-via-node {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 7px 3px 4px; border-radius: 11px;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--text); font-size: 12px; cursor: default;
}
button.dm-via-node { cursor: pointer; }
button.dm-via-node:hover { border-color: rgba(25, 229, 138, 0.5); }
.dm-via-node-ava { width: 20px; height: 20px; border-radius: 50%; overflow: hidden; flex: 0 0 auto; }
.dm-via-node-ava .avatar { width: 20px; height: 20px; min-width: 20px; min-height: 20px; border: none; box-shadow: none; }
.dm-via-node-ava .avatar-fallback { font-size: 9px; font-weight: 700; }
.dm-via-me { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: linear-gradient(150deg, #F0B82E, #D49F22); color: #1a1205; font-size: 10px; font-weight: 700; }
.dm-via-node-name { white-space: nowrap; }
.dm-via-arrow { font-size: 12px; color: rgba(25, 229, 138, 0.8); }
.screen-content:has(> .dm-screen) {
scrollbar-width: none;
-ms-overflow-style: none;
}
.screen-content:has(> .dm-screen)::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.dm-history-loader {
position: sticky;
top: 0;
z-index: 3;
display: flex;
justify-content: center;
pointer-events: none;
margin-bottom: -4px;
opacity: 0;
transform: translateY(-8px);
transition: opacity 160ms ease, transform 160ms ease;
}
.dm-history-loader[hidden] {
display: none;
}
.dm-history-loader.is-visible {
opacity: 1;
transform: translateY(0);
}
.dm-history-loader__pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 34px;
padding: 7px 14px;
border-radius: 999px;
border: 1px solid transparent;
background: rgba(9, 15, 26, 0.92);
color: rgba(245, 226, 179, 0.96);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.dm-history-loader__label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
.dm-history-loader__spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid transparent;
border-top-color: transparent;
box-shadow: 0 0 10px rgba(212, 175, 55, 0.18);
animation: dm-history-loader-spin 0.85s linear infinite;
}
@keyframes dm-history-loader-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.dm-actions-col {
display: grid;
grid-template-columns: repeat(2, 46px);
grid-template-rows: 46px;
grid-template-areas: "emoji send";
gap: 6px;
align-self: end;
}
.dm-screen .input {
background: rgba(255, 255, 255, 0.04);
border: 1px solid transparent;
border-radius: 14px;
color: rgba(255, 255, 255, 0.92);
}
.dm-floating-menu-layer {
position: fixed;
inset: 0;
z-index: 120;
pointer-events: none;
}
.dm-confirm-check {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(17, 26, 46, 0.62);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #d9e6ff;
font-size: 14px;
}
.dm-confirm-check input {
margin-top: 2px;
}
/* ===== Empty states alignment + transparent wrappers cleanup ===== */
.dm-screen::before {
position: absolute;
inset: -12px -12px 0;
z-index: -1;
pointer-events: none;
background: radial-gradient(260px 260px at 86% 10%, rgba(147, 112, 219, 0.25), transparent 72%),
radial-gradient(220px 220px at 14% 84%, rgba(147, 112, 219, 0.2), transparent 72%),
radial-gradient(220px 220px at 76% 68%, rgba(212, 175, 55, 0.16), transparent 75%),
radial-gradient(190px 190px at 26% 20%, rgba(212, 175, 55, 0.12), transparent 74%),
#05070A;
animation: dm-orbs-drift 16s ease-in-out infinite alternate;
content: none;
}
.dm-screen .dm-list > .card.meta-muted {
margin: 0 20px;
padding: 14px 16px;
border-radius: 14px;
background: rgba(18, 24, 38, 0.42);
backdrop-filter: blur(25px);
-webkit-backdrop-filter: blur(25px);
border: 1px solid transparent;
color: rgba(225, 233, 248, 0.86);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
width: calc(100% - 40px);
}
/* «Личные»: вместо жёлтого шестиугольника — текущий аватар пользователя. */
/* Список личных чатов: без цветной границы карточки, компактная ава как в шапке
* самого чата, а дата/время стоит на строке превью. */
.dm-list-screen .dm-card--family,
.dm-list-screen .dm-card--shining {
border: 0;
}
.dm-list-screen .dm-av {
width: 60px;
height: 60px;
min-width: 60px;
min-height: 60px;
}
.dm-list-screen .dm-av .avatar,
.dm-list-screen .list-item .avatar {
width: 56px;
height: 56px;
min-width: 56px;
min-height: 56px;
font-size: 18px;
border: 0;
box-shadow: none;
}
.dm-list-screen .dm-row-main {
display: grid;
grid-template-rows: 22px 20px;
align-items: center;
gap: 0;
min-height: 42px;
}
.dm-list-screen .dm-row-titlewrap {
flex-wrap: nowrap;
min-height: 22px;
overflow: hidden;
}
.dm-list-screen .dm-row-last-message {
align-self: center;
line-height: 20px;
}
.dm-list-screen .dm-unread-badge,
.dm-list-screen .dm-row-meta-spacer {
grid-row: 1;
align-self: center;
}
.dm-list-screen .dm-row-time {
grid-row: 2;
align-self: center;
line-height: 20px;
white-space: nowrap;
}
.dm-list-screen .dm-row-meta-line {
display: contents;
}
.dm-list-screen .dm-chevron {
display: none;
}
@media (max-width: 380px) {
.dm-list-screen .dm-dialog-card {
grid-template-columns: 60px minmax(0, 1fr) auto;
}
.dm-list-screen .dm-row-meta-col {
grid-column: 3;
justify-self: end;
}
}
/* ===== DM + Channels polish (2026-08-22 13:22) ===== */
/* «Личные»: аватар основной вкладки стоит ровно на той же горизонтальной позиции,
* что и аватар собеседника в открытом чате: после 40px back-slot + 8px gap. */
/* ===== DM avatar + channel row visual alignment fix (2026-08-22 13:29) ===== */
/* Шапка «Личных» переносится в topbar-slot отдельно от .dm-list-screen,
* поэтому позиционируем аватар по реальной DOM-структуре. 48px = back 40px + gap 8px
* в открытом чате, что даёт одинаковую абсолютную X-позицию. */
/* «Контакты»: верхняя аватарка убрана полностью. Пустая левая колонка сохраняет
* строгий центр заголовка относительно правого overflow-меню. */
.topbar-slot .dm-head-brand {
margin-left: 0;
min-width: 40px;
width: 40px;
height: 40px;
}
.dm-menu-icon,
.dm-menu-image-icon {
width: 18px;
height: 18px;
min-width: 18px;
flex: 0 0 18px;
object-fit: contain;
color: #65b7ff;
filter: drop-shadow(0 0 5px rgba(39, 141, 255, 0.28));
}
.dm-user-identity-menu {
width: 196px;
}
.dm-user-menu-layer {
pointer-events: auto;
}
.dm-actions-col {
grid-template-columns: repeat(2, 43px);
grid-template-rows: 46px;
gap: 2px;
}
/* Loader не имеет права участвовать в растяжении пустого flex-контейнера. */
.dm-history-loader {
flex: 0 0 34px;
align-self: center;
width: max-content;
max-width: calc(100% - 24px);
height: 34px;
min-height: 34px;
max-height: 34px;
top: calc(var(--topbar-height, 64px) + 8px);
margin: 0 auto -4px;
overflow: visible;
}
.dm-history-loader__pill {
flex: 0 0 auto;
width: max-content;
max-width: 100%;
height: 34px;
}
/* «Личные»: широкие, низкие и более плотные плитки. */
.dm-list-screen .dm-list {
width: calc(100% + 16px);
margin-inline: -8px;
gap: 4px;
}
.dm-list-screen .dm-dialog-card {
border: 0;
box-shadow: none;
background: rgba(7, 10, 18, 0.42);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
grid-template-columns: 50px minmax(0, 1fr) auto;
min-height: 54px;
margin: 0;
padding: 5px 6px 5px 4px;
gap: 8px;
border-radius: 15px;
}
.dm-list-screen .dm-av,
.dm-list-screen .dm-av .avatar,
.dm-list-screen .list-item .avatar {
width: 48px;
height: 48px;
min-width: 48px;
min-height: 48px;
}
.dm-list-screen .dm-row-main {
min-height: 40px;
}
.dm-list-screen .dm-row-meta-col {
display: grid;
grid-template-rows: 22px 20px;
align-items: center;
gap: 0;
align-self: center;
justify-items: end;
justify-content: initial;
min-height: 40px;
}
@media (max-width: 380px) {
.dm-list-screen .dm-dialog-card {
grid-template-columns: 50px minmax(0, 1fr) auto;
}
}
.dm-head-filter-title {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
}
.dm-head-filter-title::after {
content: "⌄";
font-size: 13px;
line-height: 1;
opacity: 0.72;
transform: translateY(-1px);
}
+396
View File
@@ -0,0 +1,396 @@
/* Connections/network feature styles layered before network-graph.css. */
.node-dot img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
border-radius: 50%;
display: block;
opacity: 0;
transition: opacity 120ms ease;
}
.network-board {
position: relative;
height: 290px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--radius-lg);
background: radial-gradient(circle at center, rgba(83, 216, 251, 0.08), rgba(255, 255, 255, 0.01));
overflow: hidden;
}
.network-screen {
position: relative;
margin: -14px -14px -24px;
min-height: calc(100dvh - 74px);
}
.app-shell--scroll-locked .screen-content .network-screen {
margin: 0 0 -24px;
}
.network-stage {
position: relative;
height: calc(100dvh - 74px);
overflow: hidden;
}
.network-board--full {
position: absolute;
inset: 0;
min-height: 0;
height: 100%;
border-radius: 0;
}
.network-legend {
display: flex;
flex-wrap: wrap;
gap: 10px;
font-size: 12px;
color: #bfd2ff;
}
.network-legend span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.legend-line,
.legend-arrow {
width: 18px;
height: 2px;
display: inline-block;
border-radius: 2px;
background: rgba(120, 179, 255, 0.95);
position: relative;
}
.legend-line.relative {
background: rgba(255, 159, 94, 0.95);
}
.legend-arrow::after {
content: "";
position: absolute;
right: -1px;
top: -3px;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid rgba(120, 179, 255, 0.95);
}
.network-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.network-link {
stroke-width: 2;
}
.network-link.is-friend {
stroke: rgba(120, 179, 255, 0.88);
}
.network-link.is-relative {
stroke: rgba(255, 159, 94, 0.9);
}
.node-dot {
position: relative;
width: 52px;
height: 52px;
margin: 0 auto 4px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 700;
background: #2b3f66;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 16px rgba(4, 8, 15, 0.35);
}
.node-badge-official {
position: absolute;
left: 50%;
top: -8px;
transform: translateX(-50%);
min-width: 26px;
height: 18px;
padding: 0 6px;
border-radius: 999px;
border: 1px solid transparent;
background: linear-gradient(180deg, rgba(255, 219, 145, 0.98), rgba(232, 165, 64, 0.98));
color: #3a2003;
font-size: 10px;
line-height: 16px;
font-weight: 800;
letter-spacing: 0.3px;
text-transform: uppercase;
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.3);
z-index: 2;
}
.node.is-shine .node-dot::before {
content: "";
position: absolute;
inset: -14px;
border-radius: 50%;
background: radial-gradient(circle, rgba(130, 235, 255, 0.62) 0%, rgba(130, 235, 255, 0.28) 42%, rgba(130, 235, 255, 0) 76%);
filter: blur(2px);
z-index: -1;
}
.node.is-shine .node-dot {
box-shadow: 0 0 0 2px rgba(143, 231, 255, 0.5), 0 0 28px rgba(102, 220, 255, 0.58), 0 8px 16px rgba(4, 8, 15, 0.35);
}
.node.is-friend .node-dot {
background: linear-gradient(165deg, #2f4f80, #2a3f62);
}
.node.is-relative .node-dot {
background: linear-gradient(165deg, #785038, #5f3e2c);
border-color: rgba(255, 194, 143, 0.55);
}
.node.center .node-dot {
width: 60px;
height: 60px;
background: linear-gradient(130deg, #3a5f8e, #3dc4df);
color: #061119;
}
.node-label {
display: grid;
gap: 1px;
margin-top: 1px;
font-size: 10px;
color: #d6e2ff;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.28);
line-height: 1.12;
}
.node-label.is-login-only .node-name {
display: none;
}
.node-name {
display: grid;
gap: 1px;
color: #f2f6ff;
}
.node-name-line {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-login {
display: block;
color: #c8dafd;
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-relation {
display: block;
color: #ffd5b3;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.network-search-candidate {
width: 100%;
justify-content: flex-start;
}
.network-search-candidate.is-selected {
border-color: rgba(132, 209, 255, 0.78);
background: rgba(65, 118, 191, 0.24);
}
.node:focus-visible .node-dot,
.node:hover .node-dot {
border-color: rgba(166, 218, 255, 0.92);
box-shadow: 0 0 0 3px rgba(77, 160, 255, 0.2), 0 8px 16px rgba(4, 8, 15, 0.35);
}
.node-menu {
position: absolute;
z-index: 3;
min-width: 240px;
padding: 8px;
}
.node-menu-actions {
display: grid;
gap: 8px;
}
/* Граф занимает ровно доступную область между общей шапкой и нижним toolbar. */
.app-shell--scroll-locked .screen-content .network-screen {
width: 100%;
height: 100%;
min-height: 0;
margin: 0;
}
.app-shell--scroll-locked .screen-content .network-stage {
width: 100%;
height: 100%;
min-height: 0;
}
/* В «Связях» screen-content намеренно без padding, поэтому тот же отступ задаём явно. */
.network-stage > .fg-filter-bar.app-top-tabs {
position: absolute;
top: var(--app-primary-tabs-top-gap);
left: 14px;
right: 14px;
z-index: 11;
min-height: var(--app-primary-tab-min-height);
padding: 0;
}
/* Одинаковая фактическая высота и типографика самих верхних чипов. */
.app-top-tabs .fg-filter-chip {
min-height: var(--app-primary-tab-min-height);
height: var(--app-primary-tab-min-height);
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-family: var(--font-main);
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
}
/* «Связи»: фильтры остаются ниже интерактивной части общего TopBar. */
.app-shell--scroll-locked .network-stage > .fg-filter-bar.app-top-tabs {
top: calc(var(--topbar-height, 64px) + var(--app-primary-tabs-top-gap, 14px));
}
/* ===== «Связи»: боковой fade, ослабленный в зоне центрального света =====
* Радиальная виньетка давала слишком общий эффект. Здесь края затухают независимо:
* по X узкой мягкой полосой от левого/правого края, а по Y сила этой полосы
* уменьшается возле источника света графа (50% / 47%) и растёт к углам.
* Поэтому центральное свечение визуально доходит почти до боковой кромки, тогда как
* верхние/нижние участки графа растворяются по краям раньше. Blur не используется. */
.app-shell--scroll-locked .network-stage::after {
content: "";
position: absolute;
inset: 0;
z-index: 10;
pointer-events: none;
background:
linear-gradient(
to right,
rgba(5, 7, 10, 0.96) 0%,
rgba(5, 7, 10, 0.72) 4%,
rgba(5, 7, 10, 0.42) 8%,
rgba(5, 7, 10, 0.18) 12%,
rgba(5, 7, 10, 0.06) 15%,
rgba(5, 7, 10, 0) 18%
) left center / 50% 100% no-repeat,
linear-gradient(
to left,
rgba(5, 7, 10, 0.96) 0%,
rgba(5, 7, 10, 0.72) 4%,
rgba(5, 7, 10, 0.42) 8%,
rgba(5, 7, 10, 0.18) 12%,
rgba(5, 7, 10, 0.06) 15%,
rgba(5, 7, 10, 0) 18%
) right center / 50% 100% no-repeat;
-webkit-mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.96) 0%,
rgba(0, 0, 0, 0.76) 18%,
rgba(0, 0, 0, 0.46) 34%,
rgba(0, 0, 0, 0.18) 47%,
rgba(0, 0, 0, 0.28) 58%,
rgba(0, 0, 0, 0.54) 72%,
rgba(0, 0, 0, 0.82) 88%,
rgba(0, 0, 0, 0.96) 100%
);
mask-image: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.96) 0%,
rgba(0, 0, 0, 0.76) 18%,
rgba(0, 0, 0, 0.46) 34%,
rgba(0, 0, 0, 0.18) 47%,
rgba(0, 0, 0, 0.28) 58%,
rgba(0, 0, 0, 0.54) 72%,
rgba(0, 0, 0, 0.82) 88%,
rgba(0, 0, 0, 0.96) 100%
);
}
+232
View File
@@ -0,0 +1,232 @@
/* Notifications feature styles. */
/* ===== Notifications glass style ===== */
.notifications-screen {
position: relative;
isolation: isolate;
color: rgba(255, 255, 255, 0.9);
min-height: 100%;
align-content: start;
}
.notifications-screen::before {
content: "";
position: absolute;
inset: -12px -12px 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(320px 320px at 86% 12%, rgba(147, 112, 219, 0.2), transparent 72%),
radial-gradient(280px 280px at 18% 82%, rgba(147, 112, 219, 0.14), transparent 72%),
radial-gradient(260px 260px at 70% 65%, rgba(212, 175, 55, 0.12), transparent 75%),
#05070A;
}
.notifications-screen .tabs {
background: rgba(20, 25, 35, 0.5);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border: 1px solid transparent;
border-radius: 16px;
}
.notifications-screen .tab-btn {
color: rgba(255, 255, 255, 0.65);
}
.notifications-screen .tab-btn.active {
background: rgba(255, 180, 0, 0.12);
border: 1px solid transparent;
color: rgba(255, 200, 50, 0.92);
}
.notifications-screen .notifications-list > .card {
background: rgba(20, 25, 35, 0.55);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
box-shadow: 0 0 60px rgba(80, 60, 180, 0.12);
}
.notifications-screen::before {
content: none;
}
/* Contacts overflow menu body portal: avoids topbar overflow hit-testing on desktop/Android. */
/* ===== Notifications: social event cards ===== */
.notifications-screen .notification-card {
gap: 10px;
}
.notification-identity {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.notification-avatar {
width: 40px;
height: 40px;
min-width: 40px;
min-height: 40px;
flex: 0 0 auto;
}
.notification-identity-text {
min-width: 0;
flex: 1 1 auto;
}
.notification-identity-primary {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.notification-person-name {
color: rgba(255, 255, 255, 0.96);
font-weight: 700;
overflow-wrap: anywhere;
}
.notification-login,
.notification-time-separator,
.notification-time {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
}
.notification-action,
.notification-content {
margin: 0;
}
.notification-action {
color: rgba(255, 255, 255, 0.66);
font-size: 13px;
}
.notification-content {
color: rgba(255, 255, 255, 0.94);
font-size: 15px;
line-height: 1.45;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notification-engagement {
display: flex;
align-items: center;
gap: 18px;
flex-wrap: wrap;
padding-top: 2px;
color: rgba(255, 255, 255, 0.58);
}
.notification-engagement-item {
display: inline-flex;
align-items: center;
gap: 5px;
min-width: 24px;
font-size: 12px;
line-height: 1;
}
.notification-engagement-icon {
font-size: 15px;
}
.notifications-screen .notification-card--clickable {
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease;
}
.notifications-screen .notification-card--clickable:hover,
.notifications-screen .notification-card--clickable:focus-visible {
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.055);
outline: none;
}
.notifications-screen .notification-card--clickable:active {
transform: scale(0.99);
}
/* Уведомления: переключатели лент повторяют стеклянные чипы экрана «Связи». */
.notifications-screen .notification-feed-tabs {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px 4px;
margin: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.notifications-screen .notification-feed-tabs .notification-tab-btn {
min-width: 92px;
text-align: center;
}
/* На обычных экранах отступ создаёт стандартный padding screen-content (14px). */
.notifications-screen .notification-feed-tabs.app-top-tabs {
min-height: var(--app-primary-tab-min-height);
padding: 0;
margin: 0;
}
.notifications-screen .notification-feed-tabs .notification-tab-btn {
min-width: 92px;
}
/* Уведомления: информационные блоки без рамок. Hover/focus не возвращает
* обводку различение сохраняется фоном и лёгкой реакцией на нажатие. */
.notifications-screen .notifications-list > .notification-card,
.notifications-screen .notifications-list > .notification-card:hover,
.notifications-screen .notifications-list > .notification-card:focus,
.notifications-screen .notifications-list > .notification-card:focus-visible,
.notifications-screen .notifications-list > .notification-card:active {
border: 0;
outline: 0;
}
+971
View File
@@ -0,0 +1,971 @@
/* Start/login/registration pre-auth flow styles. */
.auth-screen {
min-height: calc(100dvh - 48px - env(safe-area-inset-bottom));
display: grid;
align-content: center;
justify-items: center;
gap: 18px;
text-align: center;
}
.initial-splash {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
align-content: center;
justify-items: center;
gap: clamp(12px, 2.4vh, 20px);
padding: 24px;
overflow: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(224, 172, 75, 0.08), transparent 26%),
radial-gradient(circle at 50% 67%, rgba(45, 81, 143, 0.06), transparent 38%),
linear-gradient(180deg, #040816 0%, #020611 54%, #01040c 100%);
font-family: "Manrope", "Inter", "SF Pro Display", system-ui, sans-serif;
opacity: 1;
visibility: visible;
transition: opacity 520ms ease, visibility 520ms ease;
}
.initial-splash.is-leaving {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.initial-splash__logo-wrap {
position: relative;
isolation: isolate;
width: clamp(156px, 42vw, 208px);
aspect-ratio: 1;
}
.initial-splash__logo-wrap::before {
content: "";
position: absolute;
inset: 16%;
z-index: -1;
border-radius: 50%;
background: rgba(214, 155, 56, 0.2);
filter: blur(30px);
animation: shine-halo-breathe 4.8s ease-in-out infinite;
}
.initial-splash__logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter:
drop-shadow(0 0 8px rgba(255, 211, 121, 0.72))
drop-shadow(0 8px 22px rgba(225, 158, 48, 0.38));
animation: shine-logo-breathe 4.8s ease-in-out infinite;
}
.initial-splash__brand {
margin-top: -2px;
color: #f4ebdd;
font-size: clamp(34px, 7vw, 46px);
font-weight: 500;
line-height: 1;
text-shadow: 0 3px 22px rgba(210, 168, 90, 0.2);
}
.auth-screen--welcome {
position: relative;
isolation: isolate;
align-content: center;
justify-items: center;
gap: clamp(12px, 2.4vh, 20px);
padding-block: 8px;
font-family: "Manrope", "Inter", "SF Pro Display", system-ui, sans-serif;
}
.screen-content.no-app-chrome:has(> .auth-screen--welcome) {
overflow: hidden;
overscroll-behavior: none;
}
.auth-screen--welcome::before {
content: "";
position: absolute;
inset: -14px -14px calc(-24px - env(safe-area-inset-bottom));
z-index: -2;
background:
radial-gradient(circle at 50% 18%, rgba(224, 172, 75, 0.08), transparent 26%),
radial-gradient(circle at 50% 67%, rgba(45, 81, 143, 0.06), transparent 38%),
linear-gradient(180deg, #040816 0%, #020611 54%, #01040c 100%);
}
.auth-screen--welcome::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
pointer-events: none;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.015) 50%, transparent);
opacity: 0.7;
}
.auth-screen--lower {
align-content: start;
padding-top: clamp(80px, 18vh, 180px);
}
.auth-logo {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
filter:
drop-shadow(0 0 8px rgba(255, 211, 121, 0.72))
drop-shadow(0 8px 22px rgba(225, 158, 48, 0.38));
animation: shine-logo-breathe 4.8s ease-in-out infinite;
}
.auth-logo-wrap {
position: relative;
width: clamp(232px, 59vw, 284px);
aspect-ratio: 1;
}
.auth-logo-wrap::before {
content: "";
position: absolute;
inset: 16%;
z-index: -1;
border-radius: 50%;
background: rgba(214, 155, 56, 0.2);
filter: blur(30px);
animation: shine-halo-breathe 4.8s ease-in-out infinite;
}
.auth-brand {
margin: -4px 0 clamp(4px, 1vh, 10px);
color: #f4ebdd;
font-size: clamp(42px, 9vw, 56px);
font-weight: 500;
line-height: 1;
letter-spacing: 0;
text-shadow: 0 3px 22px rgba(210, 168, 90, 0.2);
}
.auth-actions,
.auth-footer-actions {
display: grid;
gap: 10px;
}
.auth-actions {
width: min(100%, 320px);
}
.shine-actions {
width: min(100%, 390px);
max-width: calc(100% - 48px);
gap: 12px;
}
.shine-btn {
position: relative;
isolation: isolate;
overflow: hidden;
width: 100%;
height: 56px;
min-height: 48px;
padding: 0 24px;
border-radius: 18px;
font-family: "Manrope", "Inter", "SF Pro Display", system-ui, sans-serif;
font-size: 18px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0;
text-align: center;
cursor: pointer;
}
.shine-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.shine-local-demo-btn {
min-height: 32px;
padding: 5px 12px;
border: 0;
background: transparent;
color: rgba(162, 180, 215, 0.68);
font-size: 12px;
cursor: pointer;
}
.shine-local-demo-btn:hover {
color: rgba(216, 226, 247, 0.9);
}
@keyframes shine-logo-breathe {
0%, 100% {
filter:
drop-shadow(0 0 8px rgba(255, 211, 121, 0.62))
drop-shadow(0 8px 22px rgba(225, 158, 48, 0.34));
}
50% {
filter:
drop-shadow(0 0 14px rgba(255, 220, 147, 0.88))
drop-shadow(0 10px 28px rgba(225, 158, 48, 0.5));
}
}
@keyframes shine-halo-breathe {
0%, 100% { opacity: 0.66; transform: scale(0.96); }
50% { opacity: 1; transform: scale(1.05); }
}
@media (min-width: 768px) {
.shine-btn {
height: 60px;
font-size: 20px;
}
}
@media (max-height: 760px) {
.initial-splash__logo-wrap {
width: 206px;
}
.initial-splash__brand {
font-size: 40px;
}
.auth-screen--welcome {
gap: 10px;
}
.auth-logo-wrap {
width: 206px;
}
.auth-brand {
font-size: 40px;
margin-bottom: 2px;
}
.shine-actions {
gap: 10px;
}
.shine-btn {
height: 52px;
}
}
@media (prefers-reduced-motion: reduce) {
.initial-splash {
transition-duration: 1ms;
}
.initial-splash__logo,
.initial-splash__logo-wrap::before,
.auth-logo,
.auth-logo-wrap::before,
.shine-btn {
animation: none;
transition: none;
}
}
.login-actions-wide {
width: 100%;
}
/* Shared visual language for every screen before a user enters the app. */
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) {
--preauth-ivory: #f4ebdd;
--preauth-muted: #b7c4de;
--preauth-line: rgba(182, 201, 235, 0.24);
--preauth-card: rgba(9, 18, 37, 0.78);
padding: 18px 16px calc(28px + env(safe-area-inset-bottom));
overflow-x: hidden;
background:
radial-gradient(circle at 50% 18%, rgba(224, 172, 75, 0.08), transparent 26%),
radial-gradient(circle at 50% 67%, rgba(45, 81, 143, 0.06), transparent 38%),
linear-gradient(180deg, #040816 0%, #020611 54%, #01040c 100%);
color: var(--preauth-ivory);
font-family: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
overscroll-behavior: contain;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) > section.stack {
width: min(100%, 420px);
margin-inline: auto;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 42px;
align-items: center;
min-height: 42px;
margin-bottom: 18px;
gap: 8px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__title {
min-width: 0;
margin: 0;
color: var(--preauth-ivory);
font-size: 20px;
font-weight: 650;
letter-spacing: 0;
line-height: 1.2;
text-align: center;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__left,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__right {
min-width: 0;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__right {
justify-content: flex-end;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__left .icon-btn,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .topbar__right .icon-btn {
display: grid;
place-items: center;
width: 40px;
min-width: 40px;
height: 40px;
min-height: 40px;
padding: 0;
border-radius: 12px;
border-color: var(--preauth-line);
background: rgba(12, 23, 46, 0.72);
color: var(--preauth-ivory);
font-size: 22px;
line-height: 1;
text-align: center;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen {
align-content: start;
justify-items: stretch;
padding-top: 0;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar {
margin-bottom: clamp(72px, 14vh, 132px);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar__title {
display: none;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-choice-screen .topbar__left .icon-btn {
justify-self: start;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .card,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-panel {
border: 1px solid var(--preauth-line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), transparent 24%),
var(--preauth-card);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.075),
0 16px 34px rgba(0, 0, 0, 0.24);
backdrop-filter: blur(16px) saturate(120%);
-webkit-backdrop-filter: blur(16px) saturate(120%);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .card {
padding: 18px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .card .card {
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-panel {
width: min(100%, 420px);
padding: 20px;
gap: 16px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-panel-title,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-faq-title,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .modal-title {
color: var(--preauth-ivory);
letter-spacing: 0;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-panel-title {
font-size: 24px;
font-weight: 650;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .field-label,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .auth-copy,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .status-line,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-word-number {
color: var(--preauth-muted);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .input,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .select {
min-height: 48px;
border-color: rgba(182, 201, 235, 0.22);
border-radius: 12px;
background: rgba(4, 11, 25, 0.64);
color: var(--preauth-ivory);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .input:focus,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .select:focus {
border-color: transparent;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .auth-footer-actions {
grid-template-columns: minmax(0, 1fr);
width: min(100%, 420px);
margin-inline: auto;
gap: 10px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) :is(.primary-btn, .secondary-btn, .ghost-btn) {
min-height: 50px;
border-radius: 14px;
font-family: inherit;
font-weight: 600;
letter-spacing: 0;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .link-card {
min-height: 50px;
border-radius: 14px;
color: var(--preauth-ivory);
font-family: inherit;
font-weight: 600;
letter-spacing: 0;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .link-card {
border-color: var(--preauth-line);
background: linear-gradient(180deg, rgba(24, 43, 79, 0.84), rgba(8, 17, 35, 0.86));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 8px 18px rgba(0, 0, 0, 0.18);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .link-card:hover {
border-color: transparent;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .preauth-local-demo-btn {
min-height: 44px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-toggle,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .session-item {
border-color: var(--preauth-line);
background: rgba(9, 19, 38, 0.58);
color: var(--preauth-ivory);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-progress {
border-color: transparent;
background: rgba(3, 9, 21, 0.62);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-progress-bar {
background: linear-gradient(90deg, #b27c38, #f1cd80);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .camera-shell {
min-height: min(48dvh, 360px);
border-radius: 16px;
border-color: var(--preauth-line);
background: #050b18;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .camera-video {
min-height: min(48dvh, 360px);
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-screen {
gap: 12px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-form {
gap: 14px;
padding: 16px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-form .field-label {
font-size: 14px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-password-single {
gap: 6px;
}
.password-length-hint {
margin: 0;
color: rgba(183, 196, 222, 0.76);
font-size: 12px;
line-height: 1.3;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .registration-toggle {
min-height: 48px;
padding: 10px 12px;
border-radius: 12px;
font-size: 15px;
}
.registration-login-status {
margin: 0;
font-size: 13px;
}
.registration-faq-link {
justify-self: center;
min-height: 30px;
padding: 4px 8px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
@media (max-width: 360px) {
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) {
padding-inline: 12px;
}
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .card,
.screen-content.preauth-flow:not(:has(> .auth-screen--welcome)) .login-panel {
padding: 16px;
}
}
.login-panel {
width: min(100%, 360px);
gap: 14px;
}
.login-panel--wide {
width: min(100%, 420px);
}
.login-panel-title {
font-size: 28px;
font-weight: 700;
color: var(--text);
text-align: center;
}
.auth-footer-actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auth-copy {
line-height: 1.45;
color: #d8e3ff;
}
.auth-status-card {
width: min(100%, 320px);
color: #d8e3ff;
}
.registration-finish-card {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.registration-finish-title {
margin: 0;
font-size: clamp(1.7rem, 6vw, 2.15rem);
line-height: 1.18;
text-wrap: balance;
}
.registration-finish-text,
.registration-finish-tx,
.registration-finish-progress {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
.registration-finish-tx-link {
color: #9fd8ff;
text-decoration: underline;
overflow-wrap: anywhere;
word-break: break-all;
}
.registration-finish-tx-link:hover,
.registration-finish-tx-link:focus-visible {
color: #c9ebff;
}
.registration-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(132, 162, 228, 0.22);
background: rgba(20, 31, 52, 0.72);
color: #eef3ff;
line-height: 1.4;
}
.registration-toggle input {
width: 18px;
height: 18px;
accent-color: #d4af37;
}
.registration-words-block[hidden] {
display: none;
}
.registration-words-block {
display: grid;
gap: 10px;
}
.registration-words-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.registration-word-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.registration-word-number {
font-size: 12px;
color: #b2c2e6;
min-width: 18px;
text-align: right;
}
.registration-word-input {
min-height: 44px;
padding-left: 10px;
}
.registration-faq-card {
gap: 12px;
}
.registration-faq-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.registration-faq-grid .ghost-btn,
.registration-faq-grid .secondary-btn {
min-height: 44px;
text-align: left;
}
@media (max-width: 740px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 460px) {
.registration-words-grid,
.registration-faq-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.registration-faq-hero {
gap: 12px;
}
.registration-faq-topic {
gap: 12px;
}
.registration-faq-title {
margin: 0;
font-size: 22px;
line-height: 1.2;
color: #f6deb0;
}
.registration-password-toggle {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
border-radius: 14px;
}
.registration-progress {
width: 100%;
height: 10px;
border: 1px solid rgba(180, 180, 180, 0.5);
border-radius: 6px;
overflow: hidden;
}
.registration-progress-bar {
height: 100%;
width: 0%;
background: rgba(80, 160, 255, 0.9);
transition: width 180ms linear;
}
/* Новый двухшаговый login flow */
.login-remote-server {
width: 100%;
padding: 14px;
border: 1px solid rgba(182, 201, 235, 0.18);
border-radius: 14px;
background: rgba(4, 11, 25, 0.44);
}
.login-server-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
overflow-wrap: anywhere;
text-decoration: none;
}
.login-device-preparation {
width: 100%;
}
.secret-generation-overlay {
position: fixed;
inset: 0;
z-index: 12000;
place-items: center;
padding: 24px;
background: rgba(1, 5, 14, 0.72);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.secret-generation-card {
width: min(100%, 290px);
place-items: center;
padding: 28px 24px;
border: 1px solid rgba(205, 220, 246, 0.18);
border-radius: 20px;
background: rgba(7, 15, 31, 0.9);
box-shadow: 0 22px 54px rgba(0, 0, 0, 0.34);
text-align: center;
}
.secret-generation-spinner {
width: 42px;
height: 42px;
border: 3px solid rgba(220, 232, 255, 0.18);
border-top-color: transparent;
border-radius: 50%;
animation: secret-generation-spin 0.82s linear infinite;
}
.secret-generation-title {
color: var(--preauth-ivory, #f4ebdd);
font-size: 18px;
font-weight: 650;
}
.secret-generation-progress {
min-height: 20px;
color: var(--preauth-muted, #a8bcdf);
font-size: 13px;
}
@keyframes secret-generation-spin {
to { transform: rotate(360deg); }
}
/* ===== Вход через другое устройство: заголовок внутри панели ===== */
.auth-screen--other-device {
justify-content: flex-start;
padding-top: max(18px, env(safe-area-inset-top));
}
.auth-screen--other-device .login-panel {
margin-top: 0;
}
.login-panel-inline-back {
width: 100%;
min-height: 42px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 0 2px;
font-size: 18px;
font-weight: 700;
text-align: left;
}
.login-panel-inline-back > span:first-child {
font-size: 24px;
line-height: 1;
}
/* ===== UX refinements 2026-08-29 ===== */
/* Start: larger breathing room between brand and actions. */
.auth-screen--welcome .auth-brand {
margin-bottom: clamp(34px, 6vh, 58px);
}
.auth-screen--welcome .shine-actions {
margin-top: 0;
}
/* Entry settings actions use the existing Start button visual language without huge width side effects. */
.screen-content.preauth-flow .server-check-btn.shine-btn {
width: auto;
height: 44px;
min-height: 44px;
padding-inline: 18px;
border-radius: 14px;
font-size: 15px;
}
.screen-content.preauth-flow .auth-footer-actions .shine-btn {
width: 100%;
}
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
/* Settings/language feature styles. */
.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;
}
/* ===== Выбор языка со стартового экрана ===== */
.language-screen {
width: min(100%, 430px);
margin-inline: auto;
}
.language-choice-card {
padding: 16px;
background: rgba(38, 43, 52, 0.74);
border: 1px solid rgba(255, 255, 255, 0.10);
}
.language-choice-hint {
margin: 0 0 4px;
text-align: center;
}
.language-choice-grid {
display: grid;
gap: 10px;
}
.language-choice-option {
min-height: 64px;
padding: 0 16px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
border: 1px solid rgba(210, 222, 241, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.035);
box-shadow: none;
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
text-align: left;
transform: none;
transition: transform 90ms ease, box-shadow 90ms ease, background-color 90ms ease, filter 120ms ease;
}
.language-choice-option:hover {
border-color: rgba(210, 222, 241, 0.16);
background: rgba(255, 255, 255, 0.035);
box-shadow: none;
color: #ffffff;
transform: none;
filter: brightness(1.12);
}
.language-choice-option:active {
color: #ffffff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.language-choice-option:focus-visible {
border: 1px solid rgba(210, 222, 241, 0.16);
background: rgba(255, 255, 255, 0.035);
box-shadow: none;
color: #ffffff;
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
transform: none;
}
.language-choice-option.is-selected,
.language-choice-option.is-selected:hover,
.language-choice-option.is-selected:focus-visible {
border: 1px solid rgba(92, 190, 255, 0.62);
background: rgba(39, 141, 255, 0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10);
}
.language-choice-option.is-selected:active {
border: 1px solid rgba(92, 190, 255, 0.62);
background: rgba(39, 141, 255, 0.12);
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), 0 0 20px rgba(39,141,255,.10);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.language-choice-name {
font-size: 17px;
font-weight: 650;
}
.language-choice-code {
color: rgba(255,255,255,.5);
font-size: 13px;
font-weight: 700;
letter-spacing: .08em;
}
/* ===== Вложенные настройки: тот же рамочный язык действий ===== */
.screen-content.settings-bordered-actions :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .settings-bordered-btn, .session-item, .profile-relative-suggest-item, .help-fab) {
border: 1px solid rgba(183, 203, 235, 0.28);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(255,255,255,.055), transparent 30%),
rgba(8, 19, 42, .58);
box-shadow:
inset 0 1px 0 rgba(255,255,255,.10),
0 5px 16px rgba(0,0,0,.18);
color: #ffffff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
padding-inline: 14px;
transform: none;
}
.screen-content.settings-bordered-actions :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .settings-bordered-btn, .session-item, .profile-relative-suggest-item, .help-fab):hover {
border-color: rgba(213, 225, 247, 0.42);
background:
linear-gradient(180deg, rgba(255,255,255,.075), transparent 30%),
rgba(10, 24, 52, .68);
color: #ffffff;
transform: none;
filter: brightness(1.12);
}
.screen-content.settings-bordered-actions :is(.primary-btn, .secondary-btn, .destructive-btn, .ghost-btn, .text-btn, .shine-btn, .settings-bordered-btn, .session-item, .profile-relative-suggest-item, .help-fab):active {
transform: none;
filter: brightness(0.9);
}
/* Language selector: actual two-position switch with explicit confirmation. */
.language-segmented-control {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
padding: 4px;
min-height: 58px;
border: 1px solid rgba(190, 210, 238, .18);
border-radius: 18px;
background: rgba(12, 22, 40, .72);
overflow: hidden;
}
.language-segmented-thumb {
position: absolute;
z-index: 0;
top: 4px;
bottom: 4px;
left: 4px;
width: calc(50% - 4px);
border: 1px solid rgba(99, 190, 255, .52);
border-radius: 14px;
background: linear-gradient(180deg, rgba(76, 166, 255, .28), rgba(30, 105, 204, .20));
box-shadow: inset 0 1px 0 rgba(255,255,255,.10), 0 0 18px rgba(63,160,255,.14);
transition: transform 180ms ease;
}
.language-choice-card[data-language="en"] .language-segmented-thumb { transform: translateX(100%); }
.language-segment {
position: relative;
z-index: 1;
min-height: 50px;
padding: 0 12px;
border: 0;
background: transparent;
background-image: none;
box-shadow: none;
color: #fff;
text-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
font-size: 16px;
font-weight: 650;
transition:
transform 90ms ease,
box-shadow 90ms ease,
background-color 90ms ease,
filter 120ms ease;
}
.language-segment:hover {
color: #fff;
background: transparent;
border: 0;
box-shadow: none;
filter: brightness(1.12);
}
.language-segment:active {
color: #fff;
background: rgba(0, 0, 0, 0.12);
border: 0;
box-shadow:
inset 0 3px 8px rgba(0, 0, 0, 0.58),
inset 0 -1px 2px rgba(255, 255, 255, 0.08);
transform: translateY(1px) scale(0.97);
filter: brightness(0.9);
}
.language-segment:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.62);
outline-offset: 2px;
}
.language-segment::before,
.language-segment::after {
background: transparent;
background-image: none;
border-color: transparent;
box-shadow: none;
}
.language-choice-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 8px;
}
.language-choice-actions .shine-btn { height: 50px; }
-188
View File
@@ -20,191 +20,3 @@ body::before {
filter: blur(100px);
pointer-events: none;
}
.app-shell {
width: min(var(--app-viewport-width, 100vw), 430px);
height: var(--app-viewport-height, 100vh);
position: fixed;
top: var(--app-viewport-offset-top, 0px);
left: calc(50% + var(--app-viewport-offset-left, 0px) / 2);
transform: translateX(-50%);
--call-minimized-bar-height: 0px;
--topbar-height: 0px;
--composer-height: 0px;
--toolbar-height: 78px;
--keyboard-offset: 0px;
background: transparent;
border-left: 1px solid transparent;
border-right: 1px solid transparent;
box-shadow: var(--shadow);
overflow: hidden;
}
.screen-content,
.topbar-slot,
.composer-slot,
.toolbar-slot,
.connection-retry-banner {
z-index: 1;
}
.screen-content {
position: absolute;
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
left: 0;
right: 0;
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
overflow-y: auto;
padding: 14px 14px 24px;
}
.screen-content.no-app-chrome {
top: var(--call-minimized-bar-height, 0px);
bottom: 0;
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.app-shell.has-minimized-call .screen-content {
padding-top: 12px;
}
.screen-content.network-scroll-lock {
overflow: hidden;
padding: 0;
}
.topbar-slot[hidden],
.composer-slot[hidden] {
display: none;
}
.topbar-slot {
position: absolute;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
padding: 0 12px;
pointer-events: none;
}
.topbar-slot > * {
pointer-events: auto;
}
.composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
transform: translateX(-50%);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
padding: 0 12px;
pointer-events: none;
}
.composer-slot > * {
pointer-events: auto;
}
.toolbar-slot {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 10px calc(4px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(7, 12, 23, 0) 0%, rgba(6, 11, 22, 0.96) 44%);
transition: opacity 0.18s ease, transform 0.18s ease;
}
body.chat-topbar-overlay .topbar-slot {
position: fixed;
top: var(--call-minimized-bar-height, 0px);
left: 0;
right: 0;
width: min(100vw, 430px);
margin: 0 auto;
transform: none;
padding: 0 12px;
}
body.chat-topbar-overlay .screen-content {
top: calc(var(--call-minimized-bar-height, 0px) + var(--topbar-height, 0px));
bottom: calc(var(--toolbar-height, 78px) + var(--composer-height, 0px));
}
body.chat-topbar-overlay .composer-slot {
position: absolute;
left: 50%;
width: min(var(--app-viewport-width, 100vw), 430px);
bottom: calc(var(--toolbar-height, 78px) + env(safe-area-inset-bottom));
}
/* При открытой клавиатуре toolbar остаётся на физическом дне экрана и
перекрывается клавиатурой. Composer прижимается ровно к верхней границе
visualViewport, а область сообщений заканчивается прямо над composer. */
.app-shell.keyboard-open .toolbar-slot {
bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
bottom: var(--keyboard-offset, 0px);
}
body.chat-topbar-overlay .app-shell.keyboard-open .screen-content {
bottom: calc(var(--keyboard-offset, 0px) + var(--composer-height, 0px));
}
.connection-retry-banner {
position: absolute;
left: 12px;
right: 12px;
bottom: calc(96px + env(safe-area-inset-bottom));
z-index: 5;
border-radius: 11px;
border: 1px solid rgba(133, 156, 201, 0.3);
background: rgba(10, 19, 37, 0.86);
color: #c6d6f7;
font-size: 12px;
line-height: 1.2;
text-align: center;
padding: 7px 10px;
pointer-events: auto;
cursor: pointer;
backdrop-filter: blur(10px);
}
.connection-retry-banner.is-connected {
border-color: rgba(124, 235, 171, 0.4);
color: #d8ffe9;
}
.connection-retry-banner.is-connecting {
border-color: transparent;
color: #ffe8bb;
}
.connection-retry-banner.is-disconnected {
border-color: rgba(228, 127, 145, 0.44);
color: #ffdce3;
}
.connection-retry-banner.is-updating {
border-color: rgba(144, 201, 255, 0.44);
color: #d9eeff;
}
@media (min-width: 900px) {
.app-shell {
top: 16px;
height: calc(100svh - 32px);
border-radius: 24px;
}
}
/* Android keyboard: composer touches the keyboard edge; only the composer moves. */
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot {
padding-bottom: 0;
}
body.chat-topbar-overlay .app-shell.keyboard-open .composer-slot .dm-chat-input {
padding-bottom: 0;
}
+4 -4
View File
@@ -69,7 +69,7 @@ a {
.notification-new-divider { text-align: center; font-size: 11px; letter-spacing: .12em; opacity: .72; padding: 6px 0; }
.channel-view-topbar { position: relative; }
.channel-header-more-menu { position: absolute; right: 10px; top: calc(100% - 4px); z-index: 80; min-width: 190px; padding: 7px; border: 1px solid rgba(255,255,255,.16); border-radius: 14px; background: rgba(18,18,28,.96); backdrop-filter: blur(18px); box-shadow: 0 14px 34px rgba(0,0,0,.35); }
.channel-header-more-menu button { width: 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 10px 12px; border-radius: 10px; }
.channel-header-more-menu button:hover { background: rgba(255,255,255,.08); }
.channel-header-more-menu button.is-danger { color: #ff8c9b; }
-6
View File
@@ -122,7 +122,6 @@
border: 0;
padding: 0;
background: transparent;
color: inherit;
cursor: pointer;
transform-origin: center center;
will-change: transform;
@@ -510,19 +509,14 @@
text-align: left;
background: transparent;
border: 0;
color: #dfe9ff;
font-size: 14px;
padding: 9px 10px;
border-radius: 9px;
cursor: pointer;
}
.fg-menu-item:hover {
background: rgba(77, 160, 255, 0.16);
}
.fg-menu-item.is-stub {
color: #7f8aa3;
cursor: default;
}