SHA256
Merge UI redesign v4 в смену ключей
Стартовый экран взят из origin/ui-redesign-v4. Конфликты разрешены с сохранением новой логики blockchain key.
This commit is contained in:
@@ -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
|
||||
? `
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user