Merge UI redesign v4 в смену ключей

Стартовый экран взят из origin/ui-redesign-v4. Конфликты разрешены с сохранением новой логики blockchain key.
This commit is contained in:
AidarKC
2026-10-01 12:52:32 +03:00
84 changed files with 2506 additions and 1567 deletions
@@ -9,6 +9,7 @@ import {
import { addArweaveWalletSecret, getArweaveBalance, getArweaveWalletChoices } from '../services/arweave-wallet-service.js';
import { escapeHtml, formatBytes, getAttachmentKind, normalizeAttachment } from '../services/attachment-format.js';
import { getStoredSolanaWalletChoices } from '../services/solana-wallet-service.js';
import { iconHtml } from './ui-icon.js';
import {
estimateTurboUploadPrice,
formatTurboCredits,
@@ -125,7 +126,7 @@ export function markArweaveAttachmentPlaced(login, attachment) {
export function formatArweaveHistoryTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
if (!Number.isFinite(value) || value <= 0) return '–';
const diffMs = Date.now() - value;
if (diffMs >= 0 && diffMs < RECENT_UPLOAD_MS) {
const minutes = Math.max(1, Math.floor(diffMs / 60000));
@@ -136,7 +137,7 @@ export function formatArweaveHistoryTime(ms) {
export function formatArweaveHistoryDateTime(ms) {
const value = Number(ms || 0);
if (!Number.isFinite(value) || value <= 0) return '—';
if (!Number.isFinite(value) || value <= 0) return '–';
return new Date(value).toLocaleString('ru-RU');
}
@@ -419,10 +420,10 @@ export function openArweaveAttachmentManager({
const renderTurboMeta = (mainMetaEl, previewMetaEl) => {
if (!mainMetaEl || !previewMetaEl) return;
const turboAddress = String(balanceInfo?.address || priceInfo?.address || '').trim();
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '—';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
const turboCredits = balanceInfo ? formatTurboCredits(balanceInfo.effectiveCredits, 6) : '–';
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '–';
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '–';
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '–';
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
const summary = selectedFile
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
@@ -433,10 +434,10 @@ export function openArweaveAttachmentManager({
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
<details class="ar-attachment-details">
<summary>Подробнее</summary>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '–'))}</div>
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
<div>SOL на этом ключе: ${solBalance}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
</details>
@@ -447,8 +448,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '—')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${formatTurboCredits(selectedPreviewPriceInfo.credits, 6)} credits` : '–')}</div>
`;
}
};
@@ -509,7 +510,7 @@ export function openArweaveAttachmentManager({
mainMetaEl.innerHTML = `
${isAvatarMode ? '' : `<div>Имя: ${escapeHtml(selectedFile?.name || 'file')}</div>`}
<div>Размер: ${escapeHtml(formatBytes(selectedFile?.size || 0))}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
<div>SHA-256: ${escapeHtml(selectedSha256 || '–')}</div>
<div>Цена: ${escapeHtml(Number(priceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
<div>Баланс кошелька: ${escapeHtml(Number(balanceInfo?.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 }))} AR</div>
`;
@@ -519,8 +520,8 @@ export function openArweaveAttachmentManager({
<div><strong>Файл превью</strong></div>
<div>Имя: ${escapeHtml(selectedPreviewFile.name)}</div>
<div>Размер превью: ${escapeHtml(formatBytes(selectedPreviewFile.size))}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '—')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '—')}</div>
<div>SHA-256 превью: ${escapeHtml(selectedPreviewSha256 || '–')}</div>
<div>Цена превью: ${escapeHtml(selectedPreviewPriceInfo ? `${Number(selectedPreviewPriceInfo.ar || 0).toLocaleString('ru-RU', { maximumFractionDigits: 6 })} AR` : '–')}</div>
`;
}
};
@@ -613,7 +614,7 @@ export function openArweaveAttachmentManager({
const showUpload = async () => {
const turboMode = isTurboUpload();
const titleText = effectiveDialogTitle
|| (turboMode ? 'Загрузить через Turbo' : (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение')));
|| (isAvatarMode ? 'Загрузить аватар' : (historyOnly ? 'Загрузить файл в блокчейн' : 'Добавить вложение'));
const uploadText = effectiveUploadButtonLabel || (historyOnly ? 'Загрузить в журнал' : 'Загрузить');
const canShowHistory = allowHistorySelection;
const canShowExisting = allowExistingTxInput;
@@ -622,11 +623,11 @@ export function openArweaveAttachmentManager({
<div class="modal-card stack ar-attachment-manager-card">
<div class="ar-attachment-manager-head">
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">✕</button>
<button class="icon-btn ar-attachment-manager-close" type="button" data-action="cancel" aria-label="Закрыть">${iconHtml('close')}</button>
</div>
<div class="tabs tabs--auto" role="radiogroup" aria-label="Способ загрузки">
<button class="tab-btn" role="radio" aria-checked="${turboMode}" type="button" data-action="switch-turbo">Turbo</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк Arweave</button>
<button class="tab-btn" role="radio" aria-checked="${!turboMode}" type="button" data-action="switch-arweave">Свой кошелёк</button>
</div>
${turboMode
? `
+17 -4
View File
@@ -28,6 +28,7 @@ export function openChannelEditor({
submitLabel = 'Ответить',
placeholder = 'Напишите ответ',
context = null,
destination = '',
key = 'reply',
extraControl = null,
initialText = '',
@@ -38,7 +39,7 @@ export function openChannelEditor({
isActive = () => true,
} = {}) {
if (!state.session.isAuthorized) {
openAuthRequiredModal({ title: 'Войдите, чтобы написать', text: 'Для публикации и ответа нужен активный профиль.' });
openAuthRequiredModal({ title: 'Нужен аккаунт', text: 'Чтобы публиковать и отвечать, войдите или зарегистрируйтесь.' });
return null;
}
const root = document.getElementById('modal-root');
@@ -77,11 +78,15 @@ export function openChannelEditor({
closeButton.type = 'button';
closeButton.className = 'icon-btn channel-editor__close';
closeButton.setAttribute('aria-label', 'Закрыть редактор');
closeButton.textContent = '×';
closeButton.innerHTML = iconHtml('close');
const heading = document.createElement('h2');
heading.id = `${id}-title`;
heading.textContent = title;
header.append(closeButton, heading, document.createElement('span'));
// Закрытие не теряет текст: черновик хранится до отправки
const draftMark = document.createElement('span');
draftMark.className = 'channel-editor__draft';
draftMark.textContent = 'Черновик';
header.append(closeButton, heading, draftMark);
const body = document.createElement('div');
body.className = 'channel-editor__body';
@@ -130,9 +135,17 @@ export function openChannelEditor({
const author = document.createElement('div');
author.className = 'channel-editor__author';
author.append(renderUserAvatar({ login: login || 'guest', className: 'avatar-plain', size: 'md' }));
const authorText = document.createElement('span');
authorText.className = 'channel-editor__author-text';
const authorName = document.createElement('strong');
authorName.textContent = login || 'Гость';
author.append(authorName);
authorText.append(authorName);
if (destination) {
const where = document.createElement('small');
where.textContent = destination;
authorText.append(where);
}
author.append(authorText);
body.append(author);
body.append(textarea, attachmentsBox, error, clearButton);
+165
View File
@@ -0,0 +1,165 @@
// Полноэкранный просмотр фото и видео из вложений: листание, «Скачать».
// Закрывается крестиком, Esc, смахиванием вниз или переходом назад.
import { iconHtml } from './ui-icon.js';
let activeClose = null;
function safeFileName(name, kind) {
const clean = String(name || '').trim().replace(/[\\/:*?"<>|]+/g, '_');
if (clean) return clean;
return kind === 'video' ? 'video.mp4' : 'image.jpg';
}
// Arweave отдаёт файл с другого адреса: атрибут download там не работает, поэтому скачиваем через blob.
async function downloadMedia(item) {
const name = safeFileName(item.name, item.kind);
try {
const response = await fetch(item.url);
if (!response.ok) throw new Error(String(response.status));
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = name;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30000);
} catch {
window.open(item.url, '_blank', 'noopener');
}
}
/**
* items: [{ url, kind: 'image' | 'video', name }], index – с какого начать.
*/
export function openMediaViewer(items = [], index = 0) {
const list = items.filter((item) => item?.url && (item.kind === 'image' || item.kind === 'video'));
if (!list.length) return;
activeClose?.();
let current = Math.max(0, Math.min(list.length - 1, index));
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'media-viewer';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Просмотр');
// Сверху – только «закрыть»; счётчик и «Скачать» – сразу под фото, по центру.
overlay.innerHTML = `
<div class="media-viewer__bar">
<button class="media-viewer__btn" type="button" data-action="close" aria-label="Закрыть">${iconHtml('close')}</button>
</div>
<div class="media-viewer__stage">
<div class="media-viewer__frame"></div>
<div class="media-viewer__controls">
<span class="media-viewer__counter"></span>
<button class="media-viewer__btn media-viewer__btn--round" type="button" data-action="download" aria-label="Скачать" title="Скачать">${iconHtml('download')}</button>
</div>
</div>
<button class="media-viewer__nav media-viewer__nav--prev" type="button" data-action="prev" aria-label="Предыдущее">${iconHtml('back')}</button>
<button class="media-viewer__nav media-viewer__nav--next" type="button" data-action="next" aria-label="Следующее">${iconHtml('open')}</button>
`;
const stage = overlay.querySelector('.media-viewer__stage');
const frame = overlay.querySelector('.media-viewer__frame');
const counter = overlay.querySelector('.media-viewer__counter');
const prevBtn = overlay.querySelector('[data-action="prev"]');
const nextBtn = overlay.querySelector('[data-action="next"]');
const render = () => {
stage.querySelectorAll('video').forEach((video) => video.pause());
const item = list[current];
let media;
if (item.kind === 'video') {
media = document.createElement('video');
media.controls = true;
media.autoplay = true;
media.playsInline = true;
} else {
media = document.createElement('img');
media.alt = item.name || '';
media.decoding = 'async';
}
media.className = 'media-viewer__media';
media.src = item.url;
frame.replaceChildren(media);
counter.textContent = list.length > 1 ? `${current + 1}/${list.length}` : '';
counter.hidden = list.length <= 1;
prevBtn.hidden = current <= 0;
nextBtn.hidden = current >= list.length - 1;
};
const go = (delta) => {
const next = current + delta;
if (next < 0 || next >= list.length) return;
current = next;
render();
};
const onKey = (event) => {
if (event.key === 'Escape') close();
else if (event.key === 'ArrowLeft') go(-1);
else if (event.key === 'ArrowRight') go(1);
};
const onPopState = () => close();
const close = () => {
if (activeClose !== close) return;
activeClose = null;
document.removeEventListener('keydown', onKey);
window.removeEventListener('popstate', onPopState);
stage.querySelectorAll('video').forEach((video) => video.pause());
overlay.remove();
};
let swiped = false;
overlay.addEventListener('click', (event) => {
// Клик, который браузер присылает после смахивания, не должен прятать панель или закрывать просмотр.
if (swiped) { swiped = false; return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'close') close();
else if (action === 'prev') go(-1);
else if (action === 'next') go(1);
else if (action === 'download') void downloadMedia(list[current]);
else if (event.target.closest('.media-viewer__media')?.tagName === 'IMG') overlay.classList.toggle('is-bare');
else if (event.target === stage || event.target === frame || event.target === overlay) close();
});
// Смахивание: вбок – листать, вниз – закрыть.
let startX = 0;
let startY = 0;
let tracking = false;
stage.addEventListener('pointerdown', (event) => {
if (event.target.tagName === 'VIDEO') return;
tracking = true;
startX = event.clientX;
startY = event.clientY;
});
stage.addEventListener('pointermove', (event) => {
if (!tracking) return;
const dy = event.clientY - startY;
if (dy > 0 && Math.abs(dy) > Math.abs(event.clientX - startX)) {
stage.style.transform = `translateY(${dy}px)`;
overlay.style.setProperty('--viewer-dim', String(Math.max(0.4, 1 - dy / 400)));
}
});
const endSwipe = (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
swiped = Math.hypot(dx, dy) > 10;
stage.style.transform = '';
overlay.style.removeProperty('--viewer-dim');
if (dy > 90 && dy > Math.abs(dx)) close();
else if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy)) go(dx < 0 ? 1 : -1);
};
stage.addEventListener('pointerup', endSwipe);
stage.addEventListener('pointercancel', endSwipe);
document.addEventListener('keydown', onKey);
window.addEventListener('popstate', onPopState);
activeClose = close;
render();
root.append(overlay);
overlay.querySelector('[data-action="close"]')?.focus({ preventScroll: true });
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { createDropdownMenu } from './dropdown-menu.js';
import { iconHtml } from './ui-icon.js';
export function attachMessageMenu(card, head, items) {
const trigger = document.createElement('button');
trigger.type = 'button';
trigger.className = 'icon-btn channel-message-more';
trigger.textContent = '⋯';
trigger.innerHTML = iconHtml('more');
trigger.setAttribute('aria-label', 'Действия сообщения');
head.append(trigger);
const menu = createDropdownMenu({ anchorEl: trigger, items });
+1 -1
View File
@@ -16,7 +16,7 @@ function statusChip({ kind, label, value, active }) {
const n = Number(value || 0);
return `
<button type="button" class="pf-status${active ? ' is-active' : ''}" data-profile-list="${esc(kind)}"
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
aria-label="${esc(label)}: подтверждений ${n}" title="Статус «${esc(label)}». Число – сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
</button>`;
}
+63
View File
@@ -0,0 +1,63 @@
import { openSheet } from './sheet.js';
import { iconHtml } from './ui-icon.js';
import { showToast } from '../services/channels-ux.js';
// «Поделиться записью / комментарием»: что именно отправляется, ссылка и «Скопировать ссылку».
// Системное меню «Поделиться» — вторым действием, если браузер его умеет.
export function openShareSheet({ title = 'Поделиться', kicker = '', text = '', url = '' } = {}) {
return openSheet({
title,
className: 'share-sheet',
content: (sheet) => {
const wrap = document.createElement('div');
wrap.className = 'share-sheet__content';
const preview = document.createElement('div');
preview.className = 'share-sheet__preview';
const k = document.createElement('strong');
k.textContent = kicker;
const t = document.createElement('p');
t.textContent = text;
preview.append(k, t);
const label = document.createElement('label');
label.className = 'share-sheet__label';
label.textContent = 'Ссылка';
const field = document.createElement('input');
field.className = 'share-sheet__link';
field.readOnly = true;
field.value = url;
field.addEventListener('focus', () => field.select());
label.append(field);
const copy = document.createElement('button');
copy.type = 'button';
copy.className = 'primary-btn share-sheet__copy';
copy.innerHTML = `${iconHtml('copy')}<span>Скопировать ссылку</span>`;
copy.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(url);
showToast('Ссылка скопирована');
sheet.close();
} catch {
field.focus();
field.select();
showToast('Выделил ссылку – скопируйте её вручную', { kind: 'error' });
}
});
wrap.append(preview, label, copy);
if (navigator.share) {
const system = document.createElement('button');
system.type = 'button';
system.className = 'secondary-btn share-sheet__system';
system.innerHTML = `${iconHtml('share')}<span>Другое приложение…</span>`;
system.addEventListener('click', async () => {
try { await navigator.share({ title: kicker, text, url }); sheet.close(); } catch { /* отменили */ }
});
wrap.append(system);
}
return wrap;
},
});
}
+60
View File
@@ -0,0 +1,60 @@
import { iconHtml } from './ui-icon.js';
// Шторка снизу (на широком экране — окно по центру) с заголовком и «✕».
// Закрывается по «✕», Escape и тапу мимо; фокус возвращается туда, откуда открыли.
// Возвращает { root, body, footer, close }. content — Node или функция (sheet) => Node.
export function openSheet({ title = '', content = null, className = '', onClose = null } = {}) {
const opener = document.activeElement;
const modal = document.createElement('div');
modal.className = `modal sheet${className ? ` ${className}` : ''}`;
const card = document.createElement('div');
card.className = 'modal-card sheet__card';
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
const head = document.createElement('div');
head.className = 'sheet__head';
const heading = document.createElement('h2');
heading.className = 'modal-title sheet__title';
heading.textContent = title;
heading.id = `sheet-title-${Math.random().toString(36).slice(2, 8)}`;
card.setAttribute('aria-labelledby', heading.id);
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'icon-btn sheet__close';
closeBtn.setAttribute('aria-label', 'Закрыть');
closeBtn.innerHTML = iconHtml('close');
head.append(heading, closeBtn);
const body = document.createElement('div');
body.className = 'sheet__body';
const footer = document.createElement('div');
footer.className = 'sheet__footer';
footer.hidden = true;
card.append(head, body, footer);
modal.append(card);
let closed = false;
const close = () => {
if (closed) return;
closed = true;
document.removeEventListener('keydown', onKeydown, true);
modal.remove();
opener?.focus?.();
onClose?.();
};
const onKeydown = (event) => {
if (event.key === 'Escape') { event.stopPropagation(); close(); }
};
modal.addEventListener('click', (event) => { if (event.target === modal) close(); });
closeBtn.addEventListener('click', close);
document.addEventListener('keydown', onKeydown, true);
const sheet = { root: modal, body, footer, close };
const node = typeof content === 'function' ? content(sheet) : content;
if (node instanceof Node) body.append(node);
if (footer.childElementCount) footer.hidden = false;
document.body.append(modal);
(card.querySelector('[autofocus]') || closeBtn).focus();
return sheet;
}
+18
View File
@@ -0,0 +1,18 @@
// Надпись «СИЯНИЕ» вразрядку: Tenor Sans (OFL) в контурах — шрифт не скачивается.
// Цвет — currentColor, поэтому надпись берёт цвет текста темы.
const NS = 'http://www.w3.org/2000/svg';
const WIDTH = 7336;
const PATH = 'M721 -129Q619 -33 479 -33Q401 -33 341 -60Q281 -87 241 -132Q201 -177 180.5 -236Q160 -295 160 -359Q160 -427 181.5 -483.5Q203 -540 242 -581Q281 -622 334.5 -645Q388 -668 451 -668Q494 -668 529.5 -658Q565 -648 592.5 -632Q620 -616 637.5 -595.5Q655 -575 662 -554H736Q721 -588 697.5 -617Q674 -646 639 -667Q604 -688 557 -700Q510 -712 450 -712Q363 -712 291.5 -687Q220 -662 169 -615.5Q118 -569 90 -503.5Q62 -438 62 -357Q62 -287 84.5 -220.5Q107 -154 154 -102.5Q201 -51 274 -19.5Q347 12 448 12Q512 12 562.5 0Q613 -12 650.5 -29.5Q688 -47 713 -67Q738 -87 751 -102ZM1542 -82V-700H1451V0H1542L1957 -625V0H2048V-700H1957ZM3316 -700V0H3225V-309H3181Q3153 -309 3134.5 -304Q3116 -299 3101 -287Q3086 -275 3073 -256Q3060 -237 3042 -209L2963 -92Q2948 -69 2933.5 -50Q2919 -31 2902 -17.5Q2885 -4 2863.5 4Q2842 12 2812 12Q2790 12 2776 10Q2762 8 2748 0V-37Q2753 -36 2758 -35.5Q2763 -35 2768 -35Q2789 -35 2805 -41Q2821 -47 2835 -58.5Q2849 -70 2862 -86.5Q2875 -103 2889 -124Q2931 -185 2954 -221.5Q2977 -258 2990.5 -278Q3004 -298 3012.5 -307Q3021 -316 3033 -322L3043 -327V-331Q2995 -334 2960.5 -352Q2926 -370 2904 -397Q2882 -424 2872 -455.5Q2862 -487 2862 -517Q2862 -544 2872.5 -576Q2883 -608 2908.5 -635.5Q2934 -663 2978 -681.5Q3022 -700 3089 -700ZM3225 -656H3124Q3086 -656 3055.5 -645Q3025 -634 3003 -614.5Q2981 -595 2969.5 -568Q2958 -541 2958 -509Q2958 -482 2968.5 -454.5Q2979 -427 3000 -404.5Q3021 -382 3051.5 -368Q3082 -354 3122 -354H3225ZM4197 -348H4616V0H4707V-700H4616V-394H4197V-700H4106V0H4197ZM5588 -82V-700H5497V0H5588L6003 -625V0H6094V-700H6003ZM6884 -700V0H7336V-55H6975V-346H7285V-394H6975V-645H7336V-700Z';
export function createShineWordmark({ className = '' } = {}) {
const svg = document.createElementNS(NS, 'svg');
svg.setAttribute('viewBox', `52 -722 ${WIDTH - 42} 744`);
svg.setAttribute('class', `shine-wordmark ${className}`.trim());
svg.setAttribute('role', 'img');
svg.setAttribute('aria-label', 'Сияние');
const path = document.createElementNS(NS, 'path');
path.setAttribute('d', PATH);
path.setAttribute('fill', 'currentColor');
svg.append(path);
return svg;
}
+6 -6
View File
@@ -96,22 +96,22 @@ function navigateWithGuestRules(pageId, navigate) {
}
if (pageId === 'messages-list') {
openAuthRequiredModal({
title: 'Личные сообщения недоступны',
text: 'Вы не авторизованы. Для личных сообщений сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы писать личные сообщения, войдите или зарегистрируйтесь.',
});
return;
}
if (pageId === 'profile-view') {
openAuthRequiredModal({
title: 'Профиль недоступен',
text: 'Вы не авторизованы. Для профиля сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы открыть профиль, войдите или зарегистрируйтесь.',
});
return;
}
if (pageId === 'notifications-view') {
openAuthRequiredModal({
title: 'Уведомления недоступны',
text: 'Вы не авторизованы. Для уведомлений сначала войдите в систему.',
title: 'Нужен аккаунт',
text: 'Чтобы получать уведомления, войдите или зарегистрируйтесь.',
});
return;
}
+3
View File
@@ -18,6 +18,9 @@ function createActionButton(action = {}, cleanupFns) {
button.append(action.iconNode);
} else if (action.icon) {
button.innerHTML = iconHtml(action.icon);
} else if (action.label === '⋮' || action.label === '⋯') {
// Меню экрана везде рисуется одной иконкой «⋯»
button.innerHTML = iconHtml('more');
} else {
button.textContent = String(action.label ?? '');
}
+28 -5
View File
@@ -1,11 +1,32 @@
const paths = {
heart: '<path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8L12 21l8.8-8.6a5.5 5.5 0 0 0 0-7.8Z"/>',
message: '<path d="M21 11.5a8.5 8.5 0 0 1-8.5 8.5H4l-2 2v-9.5A8.5 8.5 0 0 1 10.5 4H13a8 8 0 0 1 8 7.5Z"/>',
heart: '<path d="M12 21S2.3 15.5 2.3 8.7C2.3 3.6 8.1 1.9 12 6.5c3.9-4.6 9.7-2.9 9.7 2.2C21.7 15.5 12 21 12 21Z"/>',
message: '<path d="M5.2 2.35h13.6a2.7 2.7 0 0 1 2.7 2.7v9.7a2.7 2.7 0 0 1-2.7 2.7h-7.1L7.2 21v-3.55h-2a2.7 2.7 0 0 1-2.7-2.7v-9.7a2.7 2.7 0 0 1 2.7-2.7Z"/>',
more: '<circle cx="4" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="20" cy="12" r="1.6" fill="currentColor" stroke="none"/>',
forward: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
download: '<path d="M12 4v11M7 10.5l5 5 5-5M5 20h14"/>',
reply: '<path d="M9 7 4 12l5 5"/><path d="M4 12h9a7 7 0 0 1 7 7"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/>',
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5.5M12 7.6v.1"/>',
trash: '<path d="M4 7h16M9.5 7V4.8h5V7M6.5 7l.9 12.2a1.9 1.9 0 0 0 1.9 1.8h5.4a1.9 1.9 0 0 0 1.9-1.8L17.5 7M10 11v6M14 11v6"/>',
'bell-off': '<path d="M8.5 4.2A6 6 0 0 1 18 9c0 4.3 1.1 6.2 2 7.2M18 17H3c0-2 3-2 3-9M10 21h4M3 3l18 18"/>',
phone: '<path d="M5 3.5h3.2l1.6 4.2-2.1 1.4a11 11 0 0 0 5.2 5.2l1.4-2.1 4.2 1.6V17a2.5 2.5 0 0 1-2.7 2.5A16 16 0 0 1 2.5 6.2 2.5 2.5 0 0 1 5 3.5Z"/>',
video: '<rect x="2.5" y="6" width="13" height="12" rx="2.5"/><path d="m15.5 10.5 6-3.5v10l-6-3.5"/>',
emoji: '<circle cx="12" cy="12" r="9"/><path d="M8.3 14.2a4.6 4.6 0 0 0 7.4 0"/><circle cx="9" cy="9.8" r=".9" fill="currentColor" stroke="none"/><circle cx="15" cy="9.8" r=".9" fill="currentColor" stroke="none"/>',
send: '<path d="M12 19V5M5.5 11.5 12 5l6.5 6.5"/>',
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/>',
image: '<rect x="3" y="4.5" width="18" height="15" rx="2.5"/><circle cx="8.5" cy="9.5" r="1.6"/><path d="m21 16-4.7-4.7L7 20.5"/>',
list: '<path d="M9 6h11M9 12h11M9 18h11"/><circle cx="4.5" cy="6" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r=".9" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r=".9" fill="currentColor" stroke="none"/>',
doc: '<path d="M6.5 3h7.5L19 8v11.5a1.5 1.5 0 0 1-1.5 1.5h-11A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3Z"/><path d="M14 3v5h5M8.5 13h7M8.5 16.5h5"/>',
close: '<path d="M6 6l12 12M18 6 6 18"/>',
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/>',
alert: '<circle cx="12" cy="12" r="9"/><path d="M12 7.5v5.5M12 16.4v.1"/>',
'check-double': '<path d="m2.5 12.5 4 4L15 8"/><path d="m10.5 16 1 .5L21 8"/>',
compose: '<path d="M13 4H6.5A2.5 2.5 0 0 0 4 6.5v11A2.5 2.5 0 0 0 6.5 20h11a2.5 2.5 0 0 0 2.5-2.5V11"/><path d="m18.5 3.8 1.7 1.7a1.3 1.3 0 0 1 0 1.8L12.4 15H9v-3.4l7.7-7.8a1.3 1.3 0 0 1 1.8 0Z"/>',
channels: '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
network: '<circle cx="12" cy="5" r="3"/><circle cx="5" cy="18" r="3"/><circle cx="19" cy="18" r="3"/><path d="m10 8-4 7m8-7 4 7M8 18h8"/>',
bell: '<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',
profile: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
share: '<path d="m8 12 8-8M9 4h7v7M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>',
share: '<path d="m14.4 3.1 7.2 7.1-7.2 6.6v-4.4C8.5 12.4 5 15.1 2.4 20c.4-7.6 4.4-11.7 12-12.4Z"/>',
search: '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/>',
link: '<path d="M10 14a4.5 4.5 0 0 0 6.4 0l3.2-3.2a4.5 4.5 0 0 0-6.4-6.4L12 5.6"/><path d="M14 10a4.5 4.5 0 0 0-6.4 0l-3.2 3.2a4.5 4.5 0 0 0 6.4 6.4l1.2-1.2"/>',
gift: '<rect x="3" y="8" width="18" height="5" rx="1"/><path d="M5 13v7a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-7M12 8v13M12 8S10.5 3 8 3.5 7 8 12 8Zm0 0s1.5-5 4-4.5S17 8 12 8Z"/>',
@@ -20,9 +41,11 @@ const paths = {
settings: '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
'user-plus': '<circle cx="10" cy="8" r="3.5"/><path d="M3.5 20a6.5 6.5 0 0 1 13 0M19 8v6M16 11h6"/>',
refresh: '<path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v4.4h-4.4"/>',
eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z"/><circle cx="12" cy="12" r="3"/>',
eyeOff: '<path d="M10.2 5.7A9.6 9.6 0 0 1 12 5.5c6 0 9.5 6.5 9.5 6.5a16.4 16.4 0 0 1-2.6 3.4M6.4 7A16.2 16.2 0 0 0 2.5 12S6 18.5 12 18.5a9 9 0 0 0 4.9-1.4"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2M3.5 3.5l17 17"/>',
clip: '<path d="m20.5 11.5-8.2 8.2a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/>',
};
export function iconHtml(name, filled = false) {
return `<svg viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
export function iconHtml(name, filled = false, className = '') {
return `<svg${className ? ` class="${className}"` : ''} viewBox="0 0 24 24" fill="${filled ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths[name] || paths.message}</svg>`;
}