SHA256
UI: новый общий стиль, тёмная/светлая тема, настраиваемая палитра
- styles/main.css: роли цветов (по умолчанию «Индиго»), шкала отступов/скруглений/шрифтов, цвета отношений для обеих тем; жёсткие цвета в стилях заменены на роли. - Палитра: пресеты и личные правки, «Оформление» Авто/День/Ночь, долгое нажатие — редактор цветов с экспортом/импортом. - Каналы: пузыри постов автора, плашки дней, строка «Написать в канал…», «О канале», создание канала с адресом из названия; лента открывается на свежих постах. - Чаты: плоский список, чипы-фильтры, пузыри, плашки дней; нижняя панель скрыта в переписке. - Корневые разделы — единая шапка; профиль и чужой профиль — общая карточка; настройки, кошелёк, сеансы — меню-списки. - Нижняя панель: иконки без подписей, бейджи на иконках. - confirmDialog вместо window.confirm/alert; на телефоне диалоги — шторки снизу. - docs/UI-Design/ISSUES-for-dev.md — найденные ошибки сервера/UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+19
-18
@@ -52,37 +52,37 @@ import {
|
||||
setContacts,
|
||||
} from './state.js';
|
||||
|
||||
import * as startView from './pages/start-view.js?v=202606142105';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
|
||||
import * as registerView from './pages/register-view.js?v=202606201650';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202606201650';
|
||||
import * as startView from './pages/start-view.js?v=202609260900';
|
||||
import * as entrySettingsView from './pages/entry-settings-view.js?v=202609260900';
|
||||
import * as registerView from './pages/register-view.js?v=202609260900';
|
||||
import * as registrationFaqView from './pages/registration-faq-view.js?v=202609260900';
|
||||
import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
|
||||
import * as registrationKeysView from './pages/registration-keys-view.js';
|
||||
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
|
||||
import * as topupView from './pages/topup-view.js';
|
||||
import * as devnetTopupView from './pages/devnet-topup-view.js';
|
||||
import * as loginView from './pages/login-view.js?v=202606150110';
|
||||
import * as loginView from './pages/login-view.js?v=202609260900';
|
||||
import * as loginCameraView from './pages/login-camera-view.js';
|
||||
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
|
||||
import * as loginPasswordView from './pages/login-password-view.js?v=202606201650';
|
||||
import * as keyStorageView from './pages/key-storage-view.js';
|
||||
import * as publicSupportQueueView from './pages/public-support-queue-view.js';
|
||||
|
||||
import * as profileView from './pages/profile-view.js?v=202607150910';
|
||||
import * as profileView from './pages/profile-view.js?v=202609260900';
|
||||
import * as profileEditView from './pages/profile-edit-view.js';
|
||||
import * as profilesView from './pages/profiles-view.js';
|
||||
import * as walletView from './pages/wallet-view.js?v=202606281930';
|
||||
import * as walletView from './pages/wallet-view.js?v=202609260900';
|
||||
import * as settingsView from './pages/settings-view.js';
|
||||
import * as accessServersView from './pages/access-servers-view.js';
|
||||
import * as developerSettingsView from './pages/developer-settings-view.js';
|
||||
import * as advancedSettingsView from './pages/advanced-settings-view.js';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202606161240';
|
||||
import * as serverSettingsView from './pages/server-settings-view.js?v=202609260900';
|
||||
import * as arweaveUploadsView from './pages/arweave-uploads-view.js';
|
||||
import * as remoteAddBlockSessionView from './pages/remote-addblock-session-view.js?v=202606281300';
|
||||
import * as deviceView from './pages/device-view.js?v=202606131435';
|
||||
import * as deviceView from './pages/device-view.js?v=202609260900';
|
||||
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202606180940';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
|
||||
import * as clientPairingView from './pages/device-pairing-view.js?v=202609260900';
|
||||
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202609260900';
|
||||
import { applyThemeMode, watchSystemTheme } from './services/theme-service.js';
|
||||
import * as deviceQrView from './pages/device-qr-view.js';
|
||||
import * as deviceCameraView from './pages/device-camera-view.js';
|
||||
@@ -93,21 +93,21 @@ import * as appLogView from './pages/app-log-view.js';
|
||||
import * as pwaDiagnosticsView from './pages/pwa-diagnostics-view.js';
|
||||
import * as solanaUsersInitView from './pages/solana-users-init-view.js';
|
||||
import * as solanaRpcCheckView from './pages/solana-rpc-check-view.js';
|
||||
import * as messagesList from './pages/messages-list.js?v=202608221218';
|
||||
import * as messagesList from './pages/messages-list.js?v=202609260900';
|
||||
import * as contactSearchView from './pages/contact-search-view.js';
|
||||
import * as chatView from './pages/chat-view.js?v=202608221218';
|
||||
import * as chatView from './pages/chat-view.js?v=202609260900';
|
||||
import * as userProfileView from './pages/user-profile-view.js';
|
||||
import * as userProfileListView from './pages/user-profile-list-view.js';
|
||||
import * as userRelationManageView from './pages/user-relation-manage-view.js';
|
||||
import * as channelsList from './pages/channels-list.js?v=202608221218';
|
||||
import * as channelsList from './pages/channels-list.js?v=202609260900';
|
||||
import * as channelView from './pages/channel-view.js';
|
||||
import * as channelAboutView from './pages/channel-about-view.js';
|
||||
import * as channelDonateView from './pages/channel-donate-view.js';
|
||||
import * as channelThreadView from './pages/channel-thread-view.js';
|
||||
import * as addChannelView from './pages/add-channel-view.js';
|
||||
import * as addPersonalPublicChatView from './pages/add-personal-public-chat-view.js';
|
||||
import * as networkView from './pages/network-view.js?v=202608221226';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202608221354';
|
||||
import * as networkView from './pages/network-view.js?v=202609260900';
|
||||
import * as notificationsView from './pages/notifications-view.js?v=202609260900';
|
||||
|
||||
const CONVERSATION_CLEAR_NOTICE_TEXT = 'История переписки очищена с этого места';
|
||||
const DM_UNSUPPORTED_FORMAT_TEXT = 'Формат сообщения не поддерживается';
|
||||
@@ -1417,7 +1417,8 @@ function renderApp() {
|
||||
try {
|
||||
const chrome = createChromeController(showAppChrome, page.pageMeta?.shellMode);
|
||||
currentChromeCleanup = () => chrome.dispose();
|
||||
if (showAppChrome) {
|
||||
// В переписке нижняя панель прячется (pageMeta.hideToolbar), чтобы не отнимать место у сообщений.
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
toolbarHeightObserver?.sync?.();
|
||||
}
|
||||
@@ -1449,7 +1450,7 @@ function refreshToolbarOnly() {
|
||||
&& !(pageId === 'language-view' && !state.session.isAuthorized);
|
||||
|
||||
clearSlot(toolbarEl, '--toolbar-height');
|
||||
if (showAppChrome) {
|
||||
if (showAppChrome && !page.pageMeta?.hideToolbar) {
|
||||
mountSlot(toolbarEl, '--toolbar-height', renderToolbar(page.pageMeta.id, navigate));
|
||||
}
|
||||
toolbarHeightObserver?.sync?.();
|
||||
|
||||
@@ -434,20 +434,23 @@ export function openArweaveAttachmentManager({
|
||||
const solBalance = balanceInfo ? `${escapeHtml(String(balanceInfo.solBalance ?? 0))} SOL` : '—';
|
||||
const uploadPrice = priceInfo ? `${formatTurboCredits(priceInfo.credits, 6)} credits` : '—';
|
||||
const fileSize = selectedFile ? formatBytes(selectedFile.size) : '—';
|
||||
const freeHint = selectedFile
|
||||
? (combinedUploadSize() <= getFreeTurboUploadBytesLimit() ? 'Да, бесплатно через Turbo' : 'Нет, нужен Turbo balance')
|
||||
: `До ${formatBytes(getFreeTurboUploadBytesLimit())} бесплатно`;
|
||||
const freeBadge = priceInfo?.isFree
|
||||
? ' <span style="color:#15803d;">(пока файлы до 100 KB бесплатно)</span>'
|
||||
const isFreeSize = selectedFile && combinedUploadSize() <= getFreeTurboUploadBytesLimit();
|
||||
const summary = selectedFile
|
||||
? `Размер ${escapeHtml(fileSize)} · ${isFreeSize
|
||||
? '<span class="ar-attachment-ok">бесплатно через Turbo</span>'
|
||||
: `нужен баланс Turbo: ${escapeHtml(uploadPrice)}`}`
|
||||
: '';
|
||||
mainMetaEl.innerHTML = `
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Turbo balance: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>Размер файла: ${escapeHtml(fileSize)}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}${freeBadge}</div>
|
||||
<div>До ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))} бесплатно: ${escapeHtml(freeHint)}</div>
|
||||
${summary ? `<div class="ar-attachment-summary">${summary}</div>` : ''}
|
||||
<details class="ar-attachment-details">
|
||||
<summary>Подробнее</summary>
|
||||
<div>Turbo-адрес: ${escapeHtml(shortAddress(turboAddress || '—'))}</div>
|
||||
<div>Баланс Turbo: ${escapeHtml(turboCredits)}</div>
|
||||
<div>SOL на этом ключе: ${solBalance}</div>
|
||||
<div>SHA-256: ${escapeHtml(selectedSha256 || '—')}</div>
|
||||
<div>Цена Turbo: ${escapeHtml(uploadPrice)}</div>
|
||||
<div>Бесплатно до ${escapeHtml(formatBytes(getFreeTurboUploadBytesLimit()))}</div>
|
||||
</details>
|
||||
`;
|
||||
previewMetaEl.innerHTML = '';
|
||||
if (selectedPreviewEnabled && selectedPreviewFile) {
|
||||
@@ -628,28 +631,40 @@ export function openArweaveAttachmentManager({
|
||||
root.innerHTML = `
|
||||
<div class="modal" data-ar-attach-modal="true">
|
||||
<div class="modal-card stack ar-attachment-manager-card">
|
||||
<h3 class="modal-title">${escapeHtml(titleText)}</h3>
|
||||
<p class="meta-muted" style="margin-top:-6px; color:#15803d;">Маленькие файлы и аватары через Turbo пока загружаются бесплатно.</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="${turboMode ? 'secondary-btn' : 'primary-btn'}" type="button" data-action="switch-arweave">Загрузка используя свой Arweave кошелёк</button>
|
||||
<button class="${turboMode ? 'primary-btn' : 'secondary-btn'}" type="button" data-action="switch-turbo">Загрузить через Turbo</button>
|
||||
${canShowHistory ? `<button class="secondary-btn" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Использовать журнал загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="secondary-btn" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Использовать существующий в Arweave файл'}</button>` : ''}
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
${turboMode
|
||||
? `
|
||||
<label class="meta-muted" for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="input ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
<p class="meta-muted">Turbo использует Solana-ключ пользователя. Маленькие файлы и аватары пока бесплатно. Если файл больше, пополните Turbo со своего SOL-кошелька.</p>
|
||||
<p class="ar-attachment-note">Маленькие файлы и аватары через Turbo пока загружаются бесплатно. Для больших файлов пополните Turbo со своего SOL-кошелька.</p>
|
||||
<div class="form-field">
|
||||
<label for="turbo-key-source">Подписывать и пополнять через</label>
|
||||
<select class="select ar-attachment-wallet-select" id="turbo-key-source"></select>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<label class="meta-muted" for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="input ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="ghost-btn" type="button" data-action="add-wallet">Добавить кошелёк</button>
|
||||
${isAvatarMode ? '<p class="meta-muted">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="meta-muted">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
<div class="form-field">
|
||||
<label for="ar-attach-wallet">Кошелёк оплаты Arweave</label>
|
||||
<select class="select ar-attachment-wallet-select" id="ar-attach-wallet"></select>
|
||||
<button class="text-btn ar-attachment-link" type="button" data-action="add-wallet">+ Добавить кошелёк</button>
|
||||
</div>
|
||||
${isAvatarMode ? '<p class="ar-attachment-note">Выберите изображение. Перед загрузкой оно будет сжато до 512×512 и сохранено в истории как аватар.</p>' : (historyOnly ? '' : '<p class="ar-attachment-note">Для надёжности лучше сначала загрузить файл в Arweave, дождаться доступности в журнале, а потом добавить его из истории загрузок.</p>')}
|
||||
`}
|
||||
${fixedFile ? '' : '<label class="meta-muted" for="ar-attach-file">Файл для загрузки</label>'}
|
||||
${fixedFile ? '' : `<input class="input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />`}
|
||||
${canShowHistory || canShowExisting ? `
|
||||
<div class="ar-attachment-alt-sources">
|
||||
${canShowHistory ? `<button class="text-btn ar-attachment-link" type="button" data-action="history-top">${isAvatarMode ? 'Выбрать из журнала' : 'Из журнала загрузок'}</button>` : ''}
|
||||
${canShowExisting ? `<button class="text-btn ar-attachment-link" type="button" data-action="existing-top">${isAvatarMode ? 'Ввести tx id' : 'Уже есть в Arweave (tx id)'}</button>` : ''}
|
||||
</div>` : ''}
|
||||
${fixedFile ? '' : `<label class="ar-attachment-file-picker">
|
||||
<input class="ar-attachment-file-input" id="ar-attach-file" type="file" ${isAvatarMode ? 'accept="image/*"' : ''} />
|
||||
<span class="ar-attachment-file-btn">Выбрать файл</span>
|
||||
<span class="ar-attachment-file-name" data-file-name="true">Файл не выбран</span>
|
||||
</label>`}
|
||||
<div class="ar-attachment-meta" data-meta-main="true"></div>
|
||||
<label class="meta-muted" data-preview-option="true" hidden>
|
||||
<input type="checkbox" data-preview-toggle="true" />
|
||||
@@ -658,10 +673,9 @@ export function openArweaveAttachmentManager({
|
||||
<div class="ar-attachment-meta" data-meta-preview="true"></div>
|
||||
<p class="meta-muted inline-error" data-error="true"></p>
|
||||
<div class="form-actions-grid">
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : ''}
|
||||
${turboMode ? '<button class="secondary-btn" type="button" data-action="topup">Пополнить Turbo</button>' : '<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>'}
|
||||
<button class="primary-btn" type="button" data-action="upload" disabled>${escapeHtml(uploadText)}</button>
|
||||
</div>
|
||||
<button class="secondary-btn" type="button" data-action="cancel">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -734,7 +748,7 @@ export function openArweaveAttachmentManager({
|
||||
}
|
||||
}
|
||||
|
||||
root.querySelector('[data-action="cancel"]')?.addEventListener('click', () => close(resolve, null));
|
||||
root.querySelectorAll('[data-action="cancel"]').forEach((btn) => btn.addEventListener('click', () => close(resolve, null)));
|
||||
root.querySelector('[data-action="topup"]')?.addEventListener('click', async () => {
|
||||
await promptTurboTopUp(mainMetaEl, previewMetaEl, errorEl);
|
||||
if (turboMode && selectedFile && selectedSha256) {
|
||||
@@ -745,6 +759,10 @@ export function openArweaveAttachmentManager({
|
||||
root.querySelector('[data-action="add-wallet"]')?.addEventListener('click', showAddWallet);
|
||||
}
|
||||
|
||||
fileEl?.addEventListener('change', () => {
|
||||
const nameEl = root.querySelector('[data-file-name="true"]');
|
||||
if (nameEl) nameEl.textContent = fileEl.files?.[0]?.name || 'Файл не выбран';
|
||||
});
|
||||
fileEl?.addEventListener('change', async () => {
|
||||
selectedFile = fileEl.files?.[0] || null;
|
||||
selectedSha256 = '';
|
||||
@@ -1041,7 +1059,7 @@ export function openArweaveAttachmentManager({
|
||||
<span>${escapeHtml(formatArweaveHistoryTime(item.uploadedAtMs))}</span>
|
||||
${hasAttachmentPreview(item) ? '<span class="ar-attachment-placement-flag">С превью</span>' : ''}
|
||||
<span class="ar-attachment-status ar-attachment-status--pending" data-status="${index}">Проверяем...</span>
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Не добавлен в SHiNE</span>' : ''}
|
||||
${item.pendingPlacement ? '<span class="ar-attachment-placement-flag">Ещё не прикреплён</span>' : ''}
|
||||
</div>
|
||||
<div class="mono-cell ar-attachment-history-txid">${escapeHtml(item.ar)}</div>
|
||||
<button class="${isSelected ? 'secondary-btn' : 'ghost-btn'}" type="button" data-pick="${index}" ${isSelected ? 'disabled' : ''}>${isSelected ? 'Уже добавлен ✓' : 'Выбрать'}</button>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { markArweaveAttachmentPlaced, openArweaveAttachmentManager } from './arweave-attachment-manager.js';
|
||||
import { composeMessageWithAttachments, MAX_MESSAGE_ATTACHMENTS } from '../services/attachment-format.js';
|
||||
import { state } from '../state.js';
|
||||
@@ -133,7 +134,6 @@ export function openChannelEditor({
|
||||
authorName.textContent = login || 'Гость';
|
||||
author.append(authorName);
|
||||
body.append(author);
|
||||
if (extraControl instanceof Node) body.append(extraControl);
|
||||
body.append(textarea, attachmentsBox, error, clearButton);
|
||||
|
||||
const footer = document.createElement('footer');
|
||||
@@ -141,7 +141,8 @@ export function openChannelEditor({
|
||||
const attachButton = document.createElement('button');
|
||||
attachButton.type = 'button';
|
||||
attachButton.className = 'secondary-btn channel-editor__attach';
|
||||
attachButton.textContent = 'Прикрепить';
|
||||
attachButton.innerHTML = `${iconHtml('clip')}<span class="sr-only">Прикрепить</span>`;
|
||||
attachButton.title = 'Прикрепить файл';
|
||||
attachButton.hidden = !allowAttachments;
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'channel-editor__counter';
|
||||
@@ -151,6 +152,10 @@ export function openChannelEditor({
|
||||
submitButton.textContent = submitLabel;
|
||||
submitButton.title = 'Отправить · Ctrl+Enter / ⌘+Enter';
|
||||
footer.append(attachButton);
|
||||
if (extraControl instanceof Node) {
|
||||
footer.classList.add('has-extra');
|
||||
footer.append(extraControl);
|
||||
}
|
||||
footer.append(counter, submitButton);
|
||||
dialog.append(header, body, footer);
|
||||
overlay.append(dialog);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// Возвращает Promise<boolean>: true — подтвердили, false — отмена, Escape или тап мимо.
|
||||
export function confirmDialog({
|
||||
title = 'Подтвердите действие',
|
||||
text = '',
|
||||
confirmLabel = 'Да',
|
||||
cancelLabel = 'Отмена',
|
||||
danger = false,
|
||||
} = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const opener = document.activeElement;
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal confirm-dialog';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card confirm-dialog__card" role="alertdialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<h2 class="modal-title" id="confirm-dialog-title"></h2>
|
||||
<p class="confirm-dialog__text"></p>
|
||||
<div class="form-actions-grid">
|
||||
<button type="button" class="secondary-btn" data-answer="no"></button>
|
||||
<button type="button" class="${danger ? 'destructive-btn confirm-dialog__danger' : 'primary-btn'}" data-answer="yes"></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
modal.querySelector('.modal-title').textContent = title;
|
||||
const textEl = modal.querySelector('.confirm-dialog__text');
|
||||
textEl.textContent = text;
|
||||
textEl.hidden = !text;
|
||||
modal.querySelector('[data-answer="no"]').textContent = cancelLabel;
|
||||
modal.querySelector('[data-answer="yes"]').textContent = confirmLabel;
|
||||
|
||||
const finish = (answer) => {
|
||||
document.removeEventListener('keydown', onKeydown, true);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
resolve(answer);
|
||||
};
|
||||
const onKeydown = (event) => {
|
||||
if (event.key === 'Escape') { event.stopPropagation(); finish(false); }
|
||||
};
|
||||
modal.addEventListener('click', (event) => {
|
||||
if (event.target === modal) { finish(false); return; }
|
||||
const answer = event.target.closest('[data-answer]')?.dataset.answer;
|
||||
if (answer) finish(answer === 'yes');
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown, true);
|
||||
document.body.append(modal);
|
||||
modal.querySelector('[data-answer="no"]').focus();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Скрытый редактор палитры: открывается долгим нажатием на «День» или «Ночь» в настройках.
|
||||
import {
|
||||
PALETTE_PRESETS,
|
||||
PALETTE_ROLES,
|
||||
exportPalette,
|
||||
getPaletteSettings,
|
||||
importPalette,
|
||||
resolvePalette,
|
||||
resolveThemeMode,
|
||||
setPaletteSettings,
|
||||
} from '../services/theme-service.js';
|
||||
|
||||
export function openPaletteEditor({ theme = resolveThemeMode(), onClose } = {}) {
|
||||
let editTheme = theme === 'light' ? 'light' : 'dark';
|
||||
const opener = document.activeElement;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal palette-editor';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card palette-editor__card" role="dialog" aria-modal="true" aria-labelledby="palette-editor-title">
|
||||
<div class="palette-editor__head">
|
||||
<h2 class="modal-title" id="palette-editor-title">Цвета оформления</h2>
|
||||
<button class="icon-btn palette-editor__close" type="button" aria-label="Закрыть">✕</button>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Основа</span>
|
||||
<div class="tabs tabs--auto palette-editor__presets" role="radiogroup" aria-label="Готовая палитра"></div>
|
||||
</div>
|
||||
<div class="palette-editor__section">
|
||||
<span class="palette-editor__label">Настраиваемая тема</span>
|
||||
<div class="tabs tabs--auto" role="radiogroup" aria-label="Тема для настройки">
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="light">День</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-edit-theme="dark">Ночь</button>
|
||||
</div>
|
||||
<p class="palette-editor__hint">Правки видны сразу. Меняется только выбранная тема.</p>
|
||||
</div>
|
||||
<div class="palette-editor__roles"></div>
|
||||
<details class="palette-editor__share">
|
||||
<summary>Поделиться палитрой</summary>
|
||||
<p class="palette-editor__hint">Скопируйте текст и отправьте команде или вставьте чужую палитру и нажмите «Применить».</p>
|
||||
<textarea class="input palette-editor__json" rows="8" spellcheck="false"></textarea>
|
||||
<p class="palette-editor__error" role="alert" hidden></p>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="copy">Скопировать</button>
|
||||
<button type="button" class="secondary-btn" data-action="import">Применить</button>
|
||||
</div>
|
||||
</details>
|
||||
<div class="palette-editor__actions">
|
||||
<button type="button" class="secondary-btn" data-action="reset-theme">Сбросить эту тему</button>
|
||||
<button type="button" class="primary-btn" data-action="done">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const card = modal.querySelector('.palette-editor__card');
|
||||
const presetsEl = modal.querySelector('.palette-editor__presets');
|
||||
const rolesEl = modal.querySelector('.palette-editor__roles');
|
||||
const jsonEl = modal.querySelector('.palette-editor__json');
|
||||
const errorEl = modal.querySelector('.palette-editor__error');
|
||||
|
||||
for (const [id, preset] of Object.entries(PALETTE_PRESETS)) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'tab-btn';
|
||||
btn.setAttribute('role', 'radio');
|
||||
btn.dataset.preset = id;
|
||||
btn.textContent = preset.label;
|
||||
presetsEl.append(btn);
|
||||
}
|
||||
|
||||
const update = (mutate) => {
|
||||
const settings = getPaletteSettings();
|
||||
const next = { preset: settings.preset, custom: { dark: { ...settings.custom.dark }, light: { ...settings.custom.light } } };
|
||||
mutate(next);
|
||||
setPaletteSettings(next);
|
||||
render();
|
||||
};
|
||||
|
||||
function render() {
|
||||
const settings = getPaletteSettings();
|
||||
const colors = resolvePalette(editTheme, settings);
|
||||
const custom = settings.custom[editTheme];
|
||||
modal.querySelectorAll('[data-preset]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.preset === settings.preset));
|
||||
});
|
||||
modal.querySelectorAll('[data-edit-theme]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.editTheme === editTheme));
|
||||
});
|
||||
rolesEl.replaceChildren(...PALETTE_ROLES.map((role) => {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'palette-editor__role';
|
||||
const changed = Boolean(custom[role.id]);
|
||||
row.innerHTML = `
|
||||
<input type="color" value="${colors[role.id]}" aria-label="${role.label}">
|
||||
<span class="palette-editor__role-name">${role.label}${changed ? ' <span class="palette-editor__changed">изменён</span>' : ''}</span>
|
||||
<code class="palette-editor__role-value">${colors[role.id]}</code>
|
||||
`;
|
||||
const input = row.querySelector('input');
|
||||
input.addEventListener('input', () => {
|
||||
document.documentElement.style.setProperty(`--${role.id}`, input.value);
|
||||
row.querySelector('code').textContent = input.value;
|
||||
});
|
||||
input.addEventListener('change', () => update((next) => { next.custom[editTheme][role.id] = input.value; }));
|
||||
return row;
|
||||
}));
|
||||
jsonEl.value = exportPalette();
|
||||
errorEl.hidden = true;
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
modal.remove();
|
||||
opener?.focus?.();
|
||||
onClose?.();
|
||||
};
|
||||
const onKeydown = (event) => { if (event.key === 'Escape') close(); };
|
||||
|
||||
modal.addEventListener('click', async (event) => {
|
||||
if (event.target === modal) { close(); return; }
|
||||
const presetBtn = event.target.closest('[data-preset]');
|
||||
if (presetBtn) { update((next) => { next.preset = presetBtn.dataset.preset; }); return; }
|
||||
// data-edit-theme, а не data-theme: data-theme стоит на <html>, и closest() находил бы его при любом клике.
|
||||
const themeBtn = event.target.closest('[data-edit-theme]');
|
||||
if (themeBtn) { editTheme = themeBtn.dataset.editTheme; render(); return; }
|
||||
if (event.target.closest('.palette-editor__close')) { close(); return; }
|
||||
const action = event.target.closest('[data-action]')?.dataset.action;
|
||||
if (action === 'done') close();
|
||||
if (action === 'reset-theme') update((next) => { next.custom[editTheme] = {}; });
|
||||
if (action === 'copy') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(jsonEl.value);
|
||||
} catch {
|
||||
jsonEl.select();
|
||||
}
|
||||
}
|
||||
if (action === 'import') {
|
||||
try {
|
||||
importPalette(jsonEl.value);
|
||||
render();
|
||||
} catch {
|
||||
errorEl.textContent = 'Не удалось прочитать палитру: проверьте, что текст скопирован целиком.';
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
render();
|
||||
document.body.append(modal);
|
||||
card.querySelector('.palette-editor__close').focus();
|
||||
return close;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// data-profile-list / data-self-profile-action / data-profile-action обрабатывают profile-view.js и user-profile-view.js.
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
|
||||
export function escapeProfileHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
const esc = escapeProfileHtml;
|
||||
|
||||
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)}». Число — сколько людей его подтвердили. Нажмите, чтобы увидеть кто.">
|
||||
<span class="pf-status-dot" aria-hidden="true"></span>${esc(label)}${n > 0 ? `<b>${n}</b>` : ''}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function statHtml({ kind, label, valueHtml, ariaLabel }) {
|
||||
return `
|
||||
<button type="button" class="pf-stat" data-profile-list="${esc(kind)}" aria-label="${esc(ariaLabel)}">
|
||||
<span class="pf-stat-value">${valueHtml}</span>
|
||||
<span class="pf-stat-label">${esc(label)}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function contactRows(card) {
|
||||
return [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
]
|
||||
.filter(([, value]) => String(value || '').trim())
|
||||
.map(([label, value]) => `
|
||||
<div class="pf-row">
|
||||
<span class="pf-row-label">${esc(label)}</span>
|
||||
<span class="pf-row-value">${esc(value)}</span>
|
||||
</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {object} opts.card карточка профиля (loadUserProfileCard)
|
||||
* @param {string} opts.login логин на случай, если в карточке его нет
|
||||
* @param {string} opts.tilesHtml плитки действий (свои/чужие)
|
||||
* @param {string} [opts.tilesWrapClass] доп. класс обёртки плиток (для меню «Добавить»)
|
||||
* @param {string} [opts.beforeTilesHtml] разметка внутри обёртки перед плитками
|
||||
*/
|
||||
export function profileCardHtml({ card, login = '', tilesHtml = '', tilesWrapClass = '', beforeTilesHtml = '', isSelf = true }) {
|
||||
const stats = card?.stats || {};
|
||||
const official = card?.accountRole === 'primary';
|
||||
const shining = card?.shineStatus === 'shining';
|
||||
const cardLogin = card?.login || login;
|
||||
const fullName = [card?.firstName, card?.lastName].map((v) => String(v || '').trim()).filter(Boolean).join(' ');
|
||||
const displayName = fullName || cardLogin || 'Профиль';
|
||||
const about = String(card?.about || '').trim();
|
||||
const spiritualPath = String(card?.spiritualPath || '').trim();
|
||||
const contacts = contactRows(card);
|
||||
const aboutRow = (about || isSelf) ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">О себе</span>
|
||||
<p class="pf-row-text${about ? '' : ' is-empty'}">${esc(about || 'Не заполнено')}</p>
|
||||
</div>` : '';
|
||||
const pathRow = spiritualPath ? `
|
||||
<div class="pf-row pf-row--block">
|
||||
<span class="pf-row-label">Духовный путь</span>
|
||||
<p class="pf-row-text">${esc(spiritualPath)}</p>
|
||||
</div>` : '';
|
||||
const listInner = aboutRow + contacts + pathRow;
|
||||
const listHtml = listInner.trim() ? `<div class="pf-list">${listInner}</div>` : '';
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const statusChips = [
|
||||
(official || Number(stats.primaryReceivedCount || 0) > 0) && statusChip({ kind: 'primary_received', label: 'Официальный', value: stats.primaryReceivedCount, active: official }),
|
||||
(shining || Number(stats.shineReceivedCount || 0) > 0) && statusChip({ kind: 'shine_received', label: 'Сияющий', value: stats.shineReceivedCount, active: shining }),
|
||||
].filter(Boolean);
|
||||
const statusesHtml = statusChips.length ? `<div class="pf-statuses">${statusChips.join('')}</div>` : '';
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
|
||||
return `
|
||||
<div class="pf-hero${shining ? ' is-shining' : ''}" aria-label="Профиль ${esc(cardLogin)}">
|
||||
<div class="pf-avatar user-profile-avatar-slot"></div>
|
||||
<h2 class="pf-name">${esc(displayName)}</h2>
|
||||
<div class="pf-login">@${esc(cardLogin)}</div>
|
||||
${statusesHtml}
|
||||
</div>
|
||||
|
||||
<div class="pf-stats">
|
||||
${statHtml({ kind: 'friends', label: 'Друзья', valueHtml: closeFriends ? `${friends}<small> · ${closeFriends} близк.</small>` : String(friends), ariaLabel: `Друзья: ${friends}, близкие: ${closeFriends}` })}
|
||||
${statHtml({ kind: 'channels_owned', label: 'Каналы', valueHtml: String(Number(stats.ownedPublicChannelsCount || 0)), ariaLabel: `Каналы: ${Number(stats.ownedPublicChannelsCount || 0)}` })}
|
||||
${statHtml({ kind: 'channels_following', label: 'Подписки', valueHtml: String(Number(stats.followingChannelsCount || 0)), ariaLabel: `Подписки: ${Number(stats.followingChannelsCount || 0)}` })}
|
||||
</div>
|
||||
|
||||
${tilesHtml ? `
|
||||
<div class="pf-tiles-wrap user-profile-actions-wrap ${esc(tilesWrapClass)}">
|
||||
${beforeTilesHtml}
|
||||
<div class="pf-tiles user-profile-actions">${tilesHtml}</div>
|
||||
</div>` : ''}
|
||||
|
||||
${listHtml}`;
|
||||
}
|
||||
|
||||
export function profileTileHtml({ icon, label, attrs = '', iconMarkup = '' }) {
|
||||
return `<button type="button" class="pf-tile user-profile-action-btn" ${attrs}>${iconMarkup || iconHtml(icon)}<span>${esc(label)}</span></button>`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
function resolveElement(value) {
|
||||
return typeof value === 'function' ? value() : value;
|
||||
}
|
||||
@@ -6,7 +7,7 @@ function buildArrowIcon() {
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'scroll-to-bottom-btn__icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = '↓';
|
||||
icon.innerHTML = iconHtml('down');
|
||||
return icon;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { resolveToolbarActive } from '../router.js';
|
||||
import { state, authService } from '../state.js';
|
||||
import { openAuthRequiredModal } from '../services/auth-required-modal.js';
|
||||
import { iconHtml as lineIcon } from './ui-icon.js';
|
||||
import { SHINE_CONNECTIONS_LOGO_SRC } from './shine-logo.js';
|
||||
|
||||
const TOOLBAR_ICONS = {
|
||||
'messages-list': '<g transform="translate(12 12) scale(.88) translate(-11.5 -13)"><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"/><circle cx="8" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="11.7" cy="12" r="1.05" class="toolbar-svg-dot"/><circle cx="15.4" cy="12" r="1.05" class="toolbar-svg-dot"/></g>',
|
||||
'channels-list': '<rect x="4" y="3" width="16" height="18" rx="3"/><path d="M8 8h8M8 12h8M8 16h5"/>',
|
||||
'notifications-view': '<path d="M6.2 16.8V11a5.8 5.8 0 0 1 11.6 0v5.8l1.7 1.7h-15Z"/><path d="M10.2 21a2 2 0 0 0 3.6 0"/>',
|
||||
'profile-view': '<circle cx="12" cy="6.8" r="3.6"/><path d="M4.4 21v-2.1a5.6 5.6 0 0 1 5.6-5.6h4a5.6 5.6 0 0 1 5.6 5.6V21Z"/>',
|
||||
};
|
||||
|
||||
const ITEMS = [
|
||||
{ pageId: 'messages-list', label: 'Личные' },
|
||||
{ pageId: 'channels-list', label: 'Каналы' },
|
||||
{ pageId: 'network-view', label: 'Связи' },
|
||||
{ pageId: 'network-view', label: 'Связи', mandala: true },
|
||||
{ pageId: 'notifications-view', label: 'Уведомления' },
|
||||
{ pageId: 'profile-view', label: 'Профиль' },
|
||||
];
|
||||
|
||||
function iconHtml(item) {
|
||||
const names = { 'messages-list': 'message', 'channels-list': 'channels', 'network-view': 'network', 'notifications-view': 'bell', 'profile-view': 'profile' };
|
||||
return lineIcon(names[item.pageId]);
|
||||
function iconHtml(item, extra = '') {
|
||||
const glyph = item.mandala
|
||||
? `<img class="toolbar-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true" />`
|
||||
: `<svg class="toolbar-svg" viewBox="0 0 24 24" aria-hidden="true">${TOOLBAR_ICONS[item.pageId]}</svg>`;
|
||||
return `<span class="toolbar-icon${item.mandala ? ' toolbar-icon--mandala' : ''}">${glyph}${extra}</span>`;
|
||||
}
|
||||
|
||||
function normalizeCounters(payload = {}) {
|
||||
@@ -44,7 +54,7 @@ function renderBadge(btn, count, ariaLabel, extraClass = '') {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = `toolbar-unread-badge${extraClass ? ` ${extraClass}` : ''}`;
|
||||
btn.append(badge);
|
||||
(btn.querySelector('.toolbar-icon') || btn).append(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? '99+' : String(count);
|
||||
badge.setAttribute('aria-label', `${ariaLabel}: ${count}`);
|
||||
@@ -127,21 +137,17 @@ export function renderToolbar(currentPageId, navigate) {
|
||||
btn.className = `toolbar-btn${item.pageId === active ? ' active' : ''}${isProfile ? ' toolbar-btn-profile' : ''}${isMessages ? ' toolbar-btn-messages' : ''}${isNetwork ? ' toolbar-btn-network' : ''}${item.hero ? ' toolbar-btn-hero' : ''}`;
|
||||
if (isProfile) {
|
||||
btn.innerHTML = `
|
||||
${iconHtml(item)}
|
||||
<span class="toolbar-label-wrap">
|
||||
<span>${item.label}</span>
|
||||
<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
${iconHtml(item, `<span id="toolbar-connection-indicator" class="toolbar-connection-indicator is-unknown">
|
||||
<span class="toolbar-connection-dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
</span>
|
||||
</span>`)}
|
||||
<span class="sr-only">${item.label}</span>
|
||||
`;
|
||||
} else if (isNetwork) {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.setAttribute('aria-label', item.label);
|
||||
btn.title = item.label;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
} else {
|
||||
btn.innerHTML = `${iconHtml(item)}<span>${item.label}</span>`;
|
||||
btn.innerHTML = `${iconHtml(item)}<span class="sr-only">${item.label}</span>`;
|
||||
}
|
||||
btn.title = item.label;
|
||||
if (isMessages) renderBadge(btn, counters.dmUnreadCount, 'Непрочитанных личных сообщений');
|
||||
if (item.pageId === 'channels-list') renderBadge(btn, counters.channelsUnreadCount, 'Непрочитанных сообщений в каналах');
|
||||
if (isNotifications) renderBadge(btn, counters.notificationsUnreadCount, 'Новых уведомлений', 'notification-toolbar-badge');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from './ui-icon.js';
|
||||
import { createDropdownMenu } from './dropdown-menu.js';
|
||||
|
||||
function appendNode(target, node) {
|
||||
@@ -15,6 +16,8 @@ function createActionButton(action = {}, cleanupFns) {
|
||||
|
||||
if (action.iconNode instanceof Node) {
|
||||
button.append(action.iconNode);
|
||||
} else if (action.icon) {
|
||||
button.innerHTML = iconHtml(action.icon);
|
||||
} else {
|
||||
button.textContent = String(action.label ?? '');
|
||||
}
|
||||
@@ -56,7 +59,7 @@ export function createTopBar({
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `icon-btn topbar__back${backAction.className ? ` ${backAction.className}` : ''}`;
|
||||
button.textContent = '←';
|
||||
button.innerHTML = iconHtml('back');
|
||||
button.setAttribute('aria-label', backAction.ariaLabel || 'Назад');
|
||||
button.title = backAction.title || 'Назад';
|
||||
button.addEventListener('click', backAction.onClick);
|
||||
|
||||
@@ -7,6 +7,20 @@ const paths = {
|
||||
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"/>',
|
||||
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"/>',
|
||||
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||
check: '<path d="m5 12.5 4.5 4.5L19 7.5"/>',
|
||||
open: '<path d="M5 12h14M13 6l6 6-6 6"/>',
|
||||
chevron: '<path d="m9 6 6 6-6 6"/>',
|
||||
back: '<path d="M19 12H5M11 5l-7 7 7 7"/>',
|
||||
down: '<path d="M12 5v14M5 12l7 7 7-7"/>',
|
||||
edit: '<path d="M4 20h4L19 9a2.8 2.8 0 0 0-4-4L4 16v4Z"/><path d="m13.5 6.5 4 4"/>',
|
||||
wallet: '<path d="M4 7.5A2.5 2.5 0 0 1 6.5 5H18v3"/><rect x="4" y="8" width="16" height="11" rx="2.5"/><circle cx="16" cy="13.5" r="1.2" fill="currentColor" stroke="none"/>',
|
||||
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"/>',
|
||||
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) {
|
||||
|
||||
@@ -177,11 +177,11 @@ function createPasswordModal() {
|
||||
<div class="stack" style="gap:0.45rem;">
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="once" checked />
|
||||
<span>Использовать root key только сейчас</span>
|
||||
<span>Использовать главный ключ только сейчас</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input type="radio" name="access-servers-key-mode" value="save" />
|
||||
<span>Сохранить root key на этом устройстве</span>
|
||||
<span>Сохранить главный ключ на этом устройстве</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-muted">client key при необходимости тоже будет временно восстановлен из пароля. Если выбрать сохранение, он тоже попадёт в зашифрованный контейнер устройства.</p>
|
||||
@@ -277,15 +277,9 @@ export function render({navigate, chrome}) {
|
||||
const passwordModal = createPasswordModal();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.className = 'settings-intro';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Где хранятся личные данные</p>
|
||||
<p class="meta-muted">
|
||||
Единственный сервер доступа хранит зашифрованную личную переписку пользователя и участвует в звонках.
|
||||
Всё, что публикуется в блокчейне SHiNE, доступно через любой сервер Сияния,
|
||||
а настройки и приватная переписка хранятся только на выбранном сервере.
|
||||
При смене сервера прежняя переписка и настройки автоматически не переносятся.
|
||||
</p>
|
||||
<p class="meta-muted">Сервер доступа хранит вашу зашифрованную переписку и настройки и помогает со звонками. Публичные записи доступны через любой сервер. При смене сервера переписка сама не переносится.</p>
|
||||
`;
|
||||
|
||||
const listCard = document.createElement('div');
|
||||
@@ -302,7 +296,7 @@ export function render({navigate, chrome}) {
|
||||
listBody.className = 'stack';
|
||||
const listStatus = document.createElement('p');
|
||||
listStatus.className = 'meta-muted';
|
||||
listStatus.textContent = 'Загрузка данных из PDA...';
|
||||
listStatus.textContent = 'Загрузка данных аккаунта...';
|
||||
listCard.append(listTitle, listHint, listBody, listStatus);
|
||||
|
||||
const addCard = document.createElement('div');
|
||||
@@ -323,7 +317,7 @@ export function render({navigate, chrome}) {
|
||||
suggestEl.hidden = true;
|
||||
const addStatus = document.createElement('p');
|
||||
addStatus.className = 'meta-muted';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для изменения списка понадобится подтверждение главным ключом.';
|
||||
const addButton = document.createElement('button');
|
||||
addButton.className = 'primary-btn';
|
||||
addButton.type = 'button';
|
||||
@@ -352,7 +346,7 @@ export function render({navigate, chrome}) {
|
||||
addInput.value = candidate.login;
|
||||
addStatus.textContent = `Выбран сервер @${candidate.login}${candidate.url ? ` (${candidate.url})` : ''}`;
|
||||
} else {
|
||||
addStatus.textContent = 'Для смены сервера понадобится подпись root key.';
|
||||
addStatus.textContent = 'Для смены сервера понадобится подтверждение главным ключом.';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -392,7 +386,7 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
listStatus.textContent = 'Читаем серверы доступа из Solana PDA...';
|
||||
listStatus.textContent = 'Читаем серверы доступа из записи аккаунта в Solana...';
|
||||
try {
|
||||
const parsed = await readShineUserPda({ login: sessionLogin, solanaEndpoint });
|
||||
const logins = uniqueLogins(parsed?.accessServers);
|
||||
@@ -409,8 +403,8 @@ export function render({navigate, chrome}) {
|
||||
renderServerList();
|
||||
refreshAddButton();
|
||||
listStatus.textContent = rows.length
|
||||
? 'Сервер доступа загружен из PDA.'
|
||||
: 'В PDA пользователя пока нет серверов доступа.';
|
||||
? 'Сервер доступа загружен из записи аккаунта.'
|
||||
: 'В записи аккаунта пока нет серверов доступа.';
|
||||
} catch (error) {
|
||||
currentAccessServers = [];
|
||||
renderServerList();
|
||||
@@ -439,7 +433,7 @@ export function render({navigate, chrome}) {
|
||||
const topupUrl = address ? getTopupSiteUrl(address) : '/devnet-topup';
|
||||
target.innerHTML = `
|
||||
<span style="display:block; margin-bottom:0.55rem;">
|
||||
Не хватает SOL на client key для оплаты Solana rent/fee при обновлении user PDA.
|
||||
Не хватает SOL на ключе устройства для оплаты комиссии Solana при обновлении записи аккаунта в Solana.
|
||||
</span>
|
||||
${address ? `<span style="display:block; overflow-wrap:anywhere; margin-bottom:0.55rem;">Кошелёк: ${escapeHtml(address)}</span>` : ''}
|
||||
<a class="primary-btn" href="${escapeHtml(topupUrl)}" target="_blank" rel="noopener" style="display:inline-flex; text-decoration:none;">Пополнить DEVNET кошелёк</a>
|
||||
@@ -500,10 +494,10 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const passwordResult = await passwordModal?.open({
|
||||
title: 'Нужен пароль для обновления серверов доступа',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление user PDA через root key. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
text: 'Чтобы изменить сервер доступа, нужно подписать обновление записи аккаунта в Solana главным ключом. Этот ключ не найден в локальном зашифрованном контейнере устройства.',
|
||||
note: savedClient
|
||||
? 'client key уже сохранён на устройстве. Из пароля будет восстановлен только root key.'
|
||||
: 'На устройстве не хватает root key и/или client key. Они будут восстановлены из пароля аккаунта.',
|
||||
? 'Ключ устройства уже сохранён на устройстве. Из пароля будет восстановлен только главный ключ.'
|
||||
: 'На устройстве не хватает главного ключа и/или ключа устройства. Они будут восстановлены из пароля аккаунта.',
|
||||
});
|
||||
if (!passwordResult) {
|
||||
throw new Error('Операция отменена пользователем.');
|
||||
@@ -513,10 +507,10 @@ export function render({navigate, chrome}) {
|
||||
const derivedRootPublic = base64ToBytes(keyBundle.rootPair.publicKeyB64);
|
||||
const derivedClientPublic = base64ToBytes(keyBundle.clientPair.publicKeyB64);
|
||||
if (!equalBytes(derivedRootPublic, currentPda.rootKey)) {
|
||||
throw new Error('Пароль не подходит: root key не совпал с user PDA.');
|
||||
throw new Error('Пароль не подходит: главный ключ не совпал с записью аккаунта.');
|
||||
}
|
||||
if (!equalBytes(derivedClientPublic, currentPda.clientKey)) {
|
||||
throw new Error('Пароль не подходит: client key не совпал с user PDA.');
|
||||
throw new Error('Пароль не подходит: ключ устройства не совпал с записью аккаунта.');
|
||||
}
|
||||
|
||||
if (passwordResult.saveRoot) {
|
||||
@@ -613,15 +607,15 @@ export function render({navigate, chrome}) {
|
||||
title: 'Сменить сервер доступа?',
|
||||
text: `Заменить текущий сервер доступа на @${resolved.serverLogin}?`,
|
||||
note: resolved.httpBase
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в Solana user PDA.',
|
||||
? `Адрес сервера: ${resolved.httpBase}\nПрежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.`
|
||||
: 'Прежняя переписка и настройки автоматически не перенесутся. Изменение будет записано в записи аккаунта в Solana.',
|
||||
onConfirm: async () => {
|
||||
const nextList = [resolved.serverLogin];
|
||||
try {
|
||||
await updateAccessServers(nextList, {
|
||||
statusTarget: addStatus,
|
||||
successText: `Сервер доступа заменён на @${resolved.serverLogin}.`,
|
||||
inFlightText: `Обновляем PDA и меняем сервер на @${resolved.serverLogin}...`,
|
||||
inFlightText: `Обновляем запись аккаунта и меняем сервер на @${resolved.serverLogin}...`,
|
||||
});
|
||||
} catch {
|
||||
// Сообщение уже показано в статусе.
|
||||
|
||||
@@ -17,7 +17,7 @@ export const pageMeta = { id: 'add-channel-view', title: 'Создание ка
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PUBLIC = 1;
|
||||
const CHANNEL_LOGIN_HINT = 'Разрешены латинские буквы, цифры, _ и -. Длина: от 3 до 32 символов. Название не должно состоять только из цифр.';
|
||||
const CHANNEL_LOGIN_HINT = 'Латиница, цифры, _ и -, от 3 до 32 символов, не только цифры.';
|
||||
const CHANNEL_LOGIN_DIGITS_ONLY_HINT = 'Имя канала не должно состоять только из цифр.';
|
||||
|
||||
function persistCreateSuccessFlash(message) {
|
||||
@@ -78,6 +78,17 @@ function shortAvatarBlockchainAddress(value) {
|
||||
return raw.slice(-24);
|
||||
}
|
||||
|
||||
const TRANSLIT = {
|
||||
а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ё: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm',
|
||||
н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'ts', ч: 'ch', ш: 'sh', щ: 'sch',
|
||||
ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya',
|
||||
};
|
||||
|
||||
function suggestChannelLogin(title) {
|
||||
const latin = Array.from(String(title || '').toLowerCase()).map((ch) => TRANSLIT[ch] ?? ch).join('');
|
||||
return latin.replace(/[^a-z0-9_-]+/g, '-').replace(/-{2,}/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 32);
|
||||
}
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
@@ -101,21 +112,31 @@ export function render({navigate, chrome}) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-name">Технический логин канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="Например: My-Channel_1" required />
|
||||
<div class="meta-muted channel-create-login-hint">${CHANNEL_LOGIN_HINT}</div>
|
||||
<div class="meta-muted channel-link-preview" id="channel-link-preview"> </div>
|
||||
<div id="channel-name-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-title">Название канала</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой канал" autocomplete="off" />
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-title-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-title-counter">0 / 50</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="channel-title">Как канал будет виден пользователям</label>
|
||||
<input id="channel-title" class="input" maxlength="50" placeholder="Например: Мой красивый канал" />
|
||||
<div class="meta-muted" id="channel-title-counter">0 / 50 символов</div>
|
||||
<div id="channel-title-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-name">Адрес канала</label>
|
||||
<input id="channel-name" class="input" maxlength="32" placeholder="my-channel" required autocomplete="off" autocapitalize="off" spellcheck="false" />
|
||||
<div class="form-field__hint channel-create-login-hint">${CHANNEL_LOGIN_HINT} Заполняется из названия, можно поправить.</div>
|
||||
<div class="form-field__hint channel-link-preview" id="channel-link-preview" hidden></div>
|
||||
<div id="channel-name-error" class="inline-error"></div>
|
||||
</div>
|
||||
|
||||
<label for="channel-description">Описание канала (необязательно)</label>
|
||||
<textarea id="channel-description" class="input" rows="4" maxlength="250" placeholder="Коротко о канале, до 250 символов"></textarea>
|
||||
<div class="meta-muted" id="channel-description-counter">0 / 250 символов</div>
|
||||
<div id="channel-description-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-field">
|
||||
<label for="channel-description">Описание <span class="form-field__optional">необязательно</span></label>
|
||||
<textarea id="channel-description" class="input" rows="3" maxlength="250" placeholder="Коротко о канале"></textarea>
|
||||
<div class="form-field__meta">
|
||||
<div id="channel-description-error" class="inline-error"></div>
|
||||
<div class="form-field__counter" id="channel-description-counter">0 / 250</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="channel-create-error" class="meta-muted inline-error"></div>
|
||||
<div class="form-actions-grid">
|
||||
@@ -166,9 +187,11 @@ export function render({navigate, chrome}) {
|
||||
const renderChannelLinkPreview = (nameValue) => {
|
||||
const normalizedChannelName = normalizeChannelDisplayName(nameValue);
|
||||
if (!ownerBlockchainName || !normalizedChannelName) {
|
||||
linkPreviewEl.innerHTML = ' ';
|
||||
linkPreviewEl.hidden = true;
|
||||
linkPreviewEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
linkPreviewEl.hidden = false;
|
||||
const directUrl = buildAbsoluteChannelUrl({
|
||||
ownerBlockchainName,
|
||||
channelName: normalizedChannelName,
|
||||
@@ -216,8 +239,8 @@ export function render({navigate, chrome}) {
|
||||
titleErrorEl.textContent = titleCheck.error;
|
||||
descriptionErrorEl.textContent = descriptionCheck.error;
|
||||
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50 символов`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250 символов`;
|
||||
titleCounterEl.textContent = `${Number(titleCheck.length || 0)} / 50`;
|
||||
descriptionCounterEl.textContent = `${Number(descriptionCheck.length || 0)} / 250`;
|
||||
|
||||
const ok = nameCheck.ok && titleCheck.ok && descriptionCheck.ok;
|
||||
submitEl.disabled = submitInFlight || !ok;
|
||||
@@ -232,16 +255,19 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
};
|
||||
|
||||
let nameEditedByHand = false;
|
||||
nameEl.addEventListener('input', () => {
|
||||
nameEditedByHand = nameEl.value.trim() !== '';
|
||||
const raw = String(nameEl.value || '');
|
||||
const sanitized = sanitizeChannelLoginInput(raw);
|
||||
if (raw !== sanitized) {
|
||||
nameEl.value = sanitized;
|
||||
window.alert(CHANNEL_LOGIN_HINT);
|
||||
}
|
||||
if (raw !== sanitized) nameEl.value = sanitized;
|
||||
updateValidation();
|
||||
if (raw !== sanitized) nameErrorEl.textContent = 'Недопустимые символы убраны: можно латиницу, цифры, _ и -.';
|
||||
});
|
||||
titleEl.addEventListener('input', () => {
|
||||
if (!nameEditedByHand) nameEl.value = suggestChannelLogin(titleEl.value);
|
||||
updateValidation();
|
||||
});
|
||||
titleEl.addEventListener('input', updateValidation);
|
||||
descriptionEl.addEventListener('input', updateValidation);
|
||||
avatarBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
@@ -285,7 +311,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const check = updateValidation();
|
||||
if (check.name && /^[0-9]+$/.test(check.name)) {
|
||||
window.alert(CHANNEL_LOGIN_DIGITS_ONLY_HINT);
|
||||
nameErrorEl.textContent = CHANNEL_LOGIN_DIGITS_ONLY_HINT;
|
||||
nameEl.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authService, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import { normalizeChannelDescription } from '../services/channel-name-rules.js';
|
||||
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый персональный публичный чат' };
|
||||
export const pageMeta = { id: 'add-personal-public-chat-view', title: 'Новый публичный чат' };
|
||||
|
||||
const CREATE_CHANNEL_FLASH_KEY = 'shine-channels-create-success';
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
@@ -52,7 +52,7 @@ export function render({navigate, chrome}) {
|
||||
screen.className = 'stack channels-screen channels-screen--add';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Новый персональный публичный чат',
|
||||
title: 'Новый публичный чат',
|
||||
back: { label: '<', onClick: () => navigate('channels-list/dialogs') },
|
||||
}));
|
||||
|
||||
|
||||
@@ -32,10 +32,9 @@ export function render({ navigate, chrome }) {
|
||||
}));
|
||||
|
||||
const intro = document.createElement('div');
|
||||
intro.className = 'card stack advanced-settings-intro';
|
||||
intro.className = 'settings-intro';
|
||||
intro.innerHTML = `
|
||||
<p class="field-label">Скрытые возможности SHiNE</p>
|
||||
<p class="meta-muted">Этот экран открывается пятью быстрыми нажатиями по логотипу в обычных настройках и имеет постоянный адрес <code>/settings/advanced</code>.</p>
|
||||
<p class="meta-muted">Скрытые возможности. Экран открывается пятью быстрыми нажатиями по логотипу в настройках.</p>
|
||||
`;
|
||||
|
||||
const developerRow = createToggleRow({
|
||||
|
||||
@@ -18,17 +18,14 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Лог приложения',
|
||||
back: { label: '←', onClick: () => navigate('developer-settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить лог', ariaLabel: 'Обновить лог', onClick: () => renderEntries() }],
|
||||
}));
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'card row';
|
||||
controls.style.justifyContent = 'flex-start';
|
||||
controls.style.gap = '8px';
|
||||
controls.style.flexWrap = 'wrap';
|
||||
controls.className = 'action-pair';
|
||||
controls.innerHTML = `
|
||||
<button class="ghost-btn" type="button" data-action="refresh">Обновить</button>
|
||||
<button class="ghost-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="ghost-btn" type="button" data-action="clear">Очистить</button>
|
||||
<button class="secondary-btn" type="button" data-action="copy-all">Скопировать всё</button>
|
||||
<button class="destructive-btn" type="button" data-action="clear">Очистить</button>
|
||||
`;
|
||||
|
||||
const status = document.createElement('div');
|
||||
@@ -77,7 +74,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!entries.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Лог пока пуст.';
|
||||
list.append(empty);
|
||||
status.className = 'status-line is-available';
|
||||
@@ -117,7 +114,6 @@ export function render({navigate, chrome}) {
|
||||
status.textContent = `Записей: ${entries.length}`;
|
||||
}
|
||||
|
||||
controls.querySelector('[data-action="refresh"]').addEventListener('click', renderEntries);
|
||||
controls.querySelector('[data-action="copy-all"]').addEventListener('click', async () => {
|
||||
const entries = getAppLogEntries();
|
||||
if (!entries.length) {
|
||||
|
||||
@@ -48,7 +48,7 @@ function renderTile(item, index) {
|
||||
if (item.pendingPlacement) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'ar-attachment-placement-flag';
|
||||
flag.textContent = 'Не добавлен в SHiNE';
|
||||
flag.textContent = 'Ещё не прикреплён к записи';
|
||||
meta.append(flag);
|
||||
}
|
||||
|
||||
@@ -92,8 +92,8 @@ export function render({navigate, chrome}) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Загруженные в этой сессии файлы появятся здесь.';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Файлов пока нет. Нажмите «+», чтобы загрузить файл заранее и прикрепить его к записи позже.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
@@ -115,7 +115,7 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: 'Загрузка файлов в блокчейн',
|
||||
title: 'Файлы в блокчейне',
|
||||
back: { onClick: () => navigate('settings-view') },
|
||||
actions: [
|
||||
{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { renderAvatar } from '../components/avatar-image.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { navigateBack, getPreviousTrackedPath } from '../router.js';
|
||||
import {
|
||||
extractLoginFromBlockchainName,
|
||||
makeProfileRoute,
|
||||
@@ -251,7 +252,7 @@ function openSupportChannelModal({ ownerLogin, ownerUser, channelTitle }) {
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
@@ -410,12 +411,21 @@ function confirmUnsubscribeModal({ channelTitle }) {
|
||||
});
|
||||
}
|
||||
|
||||
function subscribersWord(count) {
|
||||
const n = Math.abs(Number(count) || 0) % 100;
|
||||
const last = n % 10;
|
||||
if (n > 10 && n < 20) return 'подписчиков';
|
||||
if (last === 1) return 'подписчик';
|
||||
if (last >= 2 && last <= 4) return 'подписчика';
|
||||
return 'подписчиков';
|
||||
}
|
||||
|
||||
export function render({ navigate, route, chrome }) {
|
||||
const screen = document.createElement('section');
|
||||
screen.className = 'stack channels-screen channels-screen--channel-about';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: '',
|
||||
title: 'О канале',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
@@ -471,61 +481,66 @@ export function render({ navigate, route, chrome }) {
|
||||
channelRootBlockHash: selector?.channelRootBlockHash,
|
||||
});
|
||||
const serverLink = buildChannelLink(shortRoute);
|
||||
const shortLink = String(serverLink || '').replace(/^https?:\/\//, '');
|
||||
const cameFromChannel = !!shortRoute && (getPreviousTrackedPath() || '').replace(/^\//, '') === String(shortRoute).replace(/^\//, '');
|
||||
const ownerName = ownerDisplayName(ownerProfile, ownerLogin);
|
||||
|
||||
const subscriptionTile = isOwnChannel ? '' : `
|
||||
<button class="channel-about-tile${isSubscribed ? ' is-active' : ''}" id="channel-about-subscription" type="button">
|
||||
${iconHtml(isSubscribed ? 'check' : 'plus')}<span>${isSubscribed ? 'Вы подписаны' : 'Подписаться'}</span>
|
||||
</button>`;
|
||||
|
||||
content.innerHTML = `
|
||||
<div class="channel-about-hero">
|
||||
<div class="channel-about-avatar-slot" id="channel-about-avatar-slot"></div>
|
||||
<h2 class="channel-about-title">${escapeHtml(cleanName)}</h2>
|
||||
<div class="channel-about-technical">${escapeHtml(ownerLogin)} / ${escapeHtml(channelName)}</div>
|
||||
<div class="channel-about-subscribers">${escapeHtml(statsText(subscribersCount))} подписчиков</div>
|
||||
<div class="channel-about-subline">@${escapeHtml(ownerLogin)} · ${escapeHtml(statsText(subscribersCount))} ${escapeHtml(subscribersWord(subscribersCount))}</div>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>О канале</h3>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</section>
|
||||
<div class="channel-about-tiles">
|
||||
${cameFromChannel ? '' : `<button class="channel-about-tile" id="channel-about-open" type="button">${iconHtml('open')}<span>Открыть</span></button>`}
|
||||
${subscriptionTile}
|
||||
<button class="channel-about-tile" id="channel-about-support" type="button">${iconHtml('gift')}<span>Донат</span></button>
|
||||
</div>
|
||||
|
||||
<section class="channel-about-section channel-about-owner-section">
|
||||
<h3>Владелец канала</h3>
|
||||
<button class="channel-about-owner-link" id="channel-about-owner" type="button">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span>@${escapeHtml(ownerLogin)}</span>
|
||||
</button>
|
||||
<button class="secondary-btn channel-about-support-btn" id="channel-about-support" type="button">Донат автору</button>
|
||||
</section>
|
||||
|
||||
|
||||
<section class="channel-about-section">
|
||||
<h3>Ссылка на этом сервере</h3>
|
||||
<div class="channel-about-link-box">
|
||||
<a href="${escapeHtml(serverLink)}">${escapeHtml(serverLink)}</a>
|
||||
<button class="icon-btn channel-about-copy-btn" id="channel-about-copy" type="button" aria-label="Скопировать ссылку" title="Скопировать ссылку">⧉</button>
|
||||
<div class="channel-about-list">
|
||||
<div class="channel-about-row channel-about-row--static">
|
||||
<span class="channel-about-row-label">Описание</span>
|
||||
<p class="channel-about-description">${escapeHtml(description || 'Описание не задано.')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button class="primary-btn channel-about-open-btn" id="channel-about-open" type="button">Открыть канал</button>
|
||||
${isOwnChannel ? '' : `
|
||||
<button class="${isSubscribed ? 'destructive-btn is-unsubscribe' : 'secondary-btn'} channel-about-subscription-btn" id="channel-about-subscription" type="button">
|
||||
${isSubscribed ? 'Отписаться от канала' : 'Подписаться на канал'}
|
||||
<button class="channel-about-row" id="channel-about-owner" type="button">
|
||||
<span class="channel-about-owner-avatar" id="channel-about-owner-avatar"></span>
|
||||
<span class="channel-about-row-main">
|
||||
<strong>${escapeHtml(ownerName)}</strong>
|
||||
<span class="channel-about-row-label">Владелец · @${escapeHtml(ownerLogin)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron">${iconHtml('chevron')}</span>
|
||||
</button>
|
||||
`}
|
||||
<button class="channel-about-row" id="channel-about-copy-row" type="button">
|
||||
<span class="channel-about-row-main">
|
||||
<span class="channel-about-row-label">Ссылка</span>
|
||||
<span class="channel-about-link">${escapeHtml(shortLink)}</span>
|
||||
</span>
|
||||
<span class="channel-about-row-chevron" aria-hidden="true">${iconHtml('link')}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (String(channel?.avaAr || '').trim()) {
|
||||
const avatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: { ar: String(channel.avaAr || '').trim() },
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
avatar.style.setProperty('--channel-avatar-size', '112px');
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(avatar);
|
||||
} else {
|
||||
content.querySelector('#channel-about-avatar-slot')?.remove();
|
||||
}
|
||||
const channelAvatar = renderAvatar({
|
||||
initials: cleanName.slice(0, 1).toUpperCase() || 'К',
|
||||
avatar: String(channel?.avaAr || '').trim() ? { ar: String(channel.avaAr || '').trim() } : null,
|
||||
size: 'xl',
|
||||
className: 'channel-about-avatar channel-profile-avatar',
|
||||
title: cleanName,
|
||||
alt: 'Аватар канала',
|
||||
});
|
||||
content.querySelector('#channel-about-avatar-slot')?.append(channelAvatar);
|
||||
content.querySelector('#channel-about-owner-avatar')?.append(renderAvatar({
|
||||
initials: ownerName.slice(0, 1).toUpperCase() || '?',
|
||||
size: 'md',
|
||||
className: 'channel-about-owner-avatar-img',
|
||||
alt: '',
|
||||
}));
|
||||
|
||||
content.querySelector('#channel-about-owner')?.addEventListener('click', () => {
|
||||
const profileRoute = makeProfileRoute(ownerLogin);
|
||||
@@ -537,7 +552,7 @@ export function render({ navigate, route, chrome }) {
|
||||
content.querySelector('#channel-about-open')?.addEventListener('click', () => {
|
||||
if (shortRoute) navigate(shortRoute);
|
||||
});
|
||||
content.querySelector('#channel-about-copy')?.addEventListener('click', async () => {
|
||||
const copyLink = async () => {
|
||||
if (!serverLink) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(serverLink);
|
||||
@@ -545,7 +560,8 @@ export function render({ navigate, route, chrome }) {
|
||||
} catch (error) {
|
||||
showToast(toUserMessage(error, 'Не удалось скопировать ссылку'), { kind: 'error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
content.querySelector('#channel-about-copy-row')?.addEventListener('click', copyLink);
|
||||
|
||||
const subscriptionButton = content.querySelector('#channel-about-subscription');
|
||||
subscriptionButton?.addEventListener('click', async () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ export function render({ navigate, route, chrome }) {
|
||||
|
||||
const login = String(state.session.login || '').trim();
|
||||
const storagePwd = String(state.session.storagePwdInMemory || '').trim();
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в SHiNE.');
|
||||
if (!login || !storagePwd) throw new Error('Для перевода нужно войти в Сиянии.');
|
||||
|
||||
let wallet;
|
||||
if (keyId === 'root-key') {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
@@ -8,6 +9,7 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
showToast,
|
||||
@@ -25,7 +27,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: 'Тред', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
export const pageMeta = { id: 'channel-thread-view', title: 'Обсуждение', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const MSG_SUBTYPE_TEXT_POST = 10;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
const MSG_SUBTYPE_TEXT_ENTRYPOINT = 100;
|
||||
@@ -200,6 +202,13 @@ function allFeedSummaries() {
|
||||
});
|
||||
}
|
||||
|
||||
const channelTitleByLabel = new Map();
|
||||
|
||||
function rememberChannelTitle(label, channel) {
|
||||
const title = String(channel?.displayName || channel?.displayTitle || '').trim();
|
||||
if (label && title) channelTitleByLabel.set(label, title);
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channelSelector) {
|
||||
const rootNumber = channelSelector?.channelRootBlockNumber ?? channelSelector?.rootBlockNumber;
|
||||
const rootHashRaw = channelSelector?.channelRootBlockHash ?? channelSelector?.rootBlockHash;
|
||||
@@ -214,7 +223,9 @@ function resolveChannelDisplayName(channelSelector) {
|
||||
&& normalizeRouteHash(summary?.channel?.channelRoot?.blockHash) === rootHash
|
||||
));
|
||||
if (!found) return '';
|
||||
return `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
const label = `${found.channel?.ownerLogin || 'неизвестно'}/${found.channel?.channelName || 'канал'}`;
|
||||
rememberChannelTitle(label, found.channel);
|
||||
return label;
|
||||
}
|
||||
|
||||
function resolveChannelHeadingFromNode(node) {
|
||||
@@ -299,7 +310,9 @@ async function resolveChannelDisplayNameFromServer(channelSelector) {
|
||||
if (!row?.channel?.channelName) return '';
|
||||
|
||||
channelSelector.channelRootBlockHash = normalizeRouteHash(row?.channel?.channelRoot?.blockHash);
|
||||
return `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
const label = `${row.channel.ownerLogin || ownerLogin}/${row.channel.channelName}`;
|
||||
rememberChannelTitle(label, row.channel);
|
||||
return label;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -446,7 +459,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="thread-blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -702,7 +715,8 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
}
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = node?.createdAtMs ? new Date(node.createdAtMs).toLocaleString() : '—';
|
||||
timestamp.textContent = node?.createdAtMs ? formatRelativeTime(node.createdAtMs) : '—';
|
||||
if (node?.createdAtMs) timestamp.title = new Date(node.createdAtMs).toLocaleString('ru-RU');
|
||||
authorBlock.append(title, timestamp);
|
||||
authorTile.append(avatar, authorBlock);
|
||||
headRow.append(authorTile);
|
||||
@@ -786,7 +800,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${likes}</span>
|
||||
<span class="channel-action-counter">${Number(likes) > 0 ? likes : ''}</span>
|
||||
`;
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${likes}`);
|
||||
@@ -817,7 +831,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${replies}</span>
|
||||
<span class="channel-action-counter">${Number(replies) > 0 ? replies : ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
@@ -856,7 +870,7 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
const discussionButton = document.createElement('button');
|
||||
discussionButton.type = 'button';
|
||||
discussionButton.className = 'ui-button channel-action-item';
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span><span>${replies}</span>`;
|
||||
discussionButton.innerHTML = `<span class="channel-action-icon">${iconHtml('message')}</span>${Number(replies) > 0 ? `<span>${replies}</span>` : ''}`;
|
||||
discussionButton.setAttribute('aria-label', `Открыть обсуждение, ответов: ${replies}`);
|
||||
discussionButton.addEventListener('click', () => handlers.onOpenThread(target));
|
||||
actions.append(likeButton, discussionButton, shareButton, replyButton);
|
||||
@@ -871,8 +885,6 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
setActionTitle(originalButton, 'Оригинал');
|
||||
originalButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
const ownerLogin = extractLoginFromBlockchainName(repostTarget.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
handlers.navigate(makeShineMessageRoute({
|
||||
@@ -927,7 +939,12 @@ function renderNodeCard(node, heading, handlers, localNumber) {
|
||||
});
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await handlers.onEdit(target, '', { isChannelPost, isDelete: true }); }
|
||||
catch (error) { if (handlers.isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
@@ -1026,21 +1043,18 @@ export function render({ navigate, route, chrome }) {
|
||||
const threadHeaderButton = document.createElement('button');
|
||||
threadHeaderButton.type = 'button';
|
||||
threadHeaderButton.className = 'icon-btn channel-header-route-btn app-topbar-title-action';
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
const threadHeaderTitle = document.createElement('span');
|
||||
threadHeaderTitle.className = 'channel-header-title';
|
||||
threadHeaderTitle.textContent = 'Обсуждение';
|
||||
const threadHeaderChannel = document.createElement('span');
|
||||
threadHeaderChannel.className = 'channel-header-owner';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.append(threadHeaderTitle, threadHeaderChannel);
|
||||
threadHeaderButton.disabled = true;
|
||||
|
||||
const header = createTopBar({
|
||||
center: threadHeaderButton,
|
||||
back: { label: '<', onClick: () => navigate(resolveThreadPreviousInChannels(selector, activeResolvedChannelLabel)) },
|
||||
actions: [
|
||||
{
|
||||
label: '↑',
|
||||
title: 'К списку каналов',
|
||||
ariaLabel: 'К списку каналов',
|
||||
className: 'channel-thread-list-btn',
|
||||
onClick: () => navigate('channels-list'),
|
||||
},
|
||||
],
|
||||
});
|
||||
header.classList.add('channel-thread-topbar');
|
||||
chrome?.setTopbar(header);
|
||||
@@ -1050,7 +1064,7 @@ export function render({ navigate, route, chrome }) {
|
||||
statusBox.style.display = 'none';
|
||||
|
||||
const ensureActive = () => {
|
||||
if (disposed) throw new Error('Экран треда уже закрыт.');
|
||||
if (disposed) throw new Error('Экран обсуждения уже закрыт.');
|
||||
};
|
||||
|
||||
const showStatus = (message) => {
|
||||
@@ -1175,10 +1189,10 @@ export function render({ navigate, route, chrome }) {
|
||||
onShare: async (target) => {
|
||||
try {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на тред.');
|
||||
if (!routePath) throw new Error('Не удалось подготовить ссылку на обсуждение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Тред',
|
||||
text: 'Сообщение из треда SHiNE',
|
||||
title: 'Сияние · Обсуждение',
|
||||
text: 'Сообщение из обсуждения в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routePath),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -1192,7 +1206,7 @@ export function render({ navigate, route, chrome }) {
|
||||
onOpenThread: (target) => {
|
||||
const routePath = buildThreadRouteFromTarget(target, selector);
|
||||
if (!routePath) {
|
||||
showStatus('Не удалось определить путь до треда.');
|
||||
showStatus('Не удалось определить ссылку на обсуждение.');
|
||||
return;
|
||||
}
|
||||
navigate(routePath);
|
||||
@@ -1264,14 +1278,14 @@ export function render({ navigate, route, chrome }) {
|
||||
showStatus('');
|
||||
selector = parseThreadSelector(route);
|
||||
activeResolvedChannelLabel = resolveChannelDisplayName(selector?.channel);
|
||||
threadHeaderButton.textContent = 'Тред в канале: ...';
|
||||
threadHeaderChannel.textContent = '…';
|
||||
threadHeaderButton.disabled = true;
|
||||
threadHeaderButton.onclick = null;
|
||||
|
||||
if (!selector) {
|
||||
const invalid = document.createElement('div');
|
||||
invalid.className = 'card meta-muted';
|
||||
invalid.textContent = 'Некорректный идентификатор треда в адресе страницы.';
|
||||
invalid.textContent = 'Обсуждение не найдено: неверная ссылка.';
|
||||
screen.append(invalid);
|
||||
return;
|
||||
}
|
||||
@@ -1382,7 +1396,8 @@ export function render({ navigate, route, chrome }) {
|
||||
const fallbackChannel = String(selector?.channel?.ownerBlockchainName || '').trim() || 'неизвестно';
|
||||
const resolvedChannelTitle = resolvedChannelLabel || fallbackChannel;
|
||||
if (threadHeaderButton) {
|
||||
threadHeaderButton.textContent = `Обсуждение · ${resolvedChannelTitle}`;
|
||||
threadHeaderChannel.textContent = `в канале «${channelTitleByLabel.get(resolvedChannelTitle) || resolvedChannelTitle}»`;
|
||||
threadHeaderButton.title = 'Открыть канал';
|
||||
threadHeaderButton.disabled = false;
|
||||
threadHeaderButton.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
@@ -1422,8 +1437,8 @@ export function render({ navigate, route, chrome }) {
|
||||
composer.className = 'channel-composer';
|
||||
const reply = document.createElement('button');
|
||||
reply.type = 'button';
|
||||
reply.className = 'primary-btn';
|
||||
reply.textContent = state.session.isAuthorized ? 'Написать ответ' : 'Войти и ответить';
|
||||
reply.className = 'channel-compose-bar';
|
||||
reply.innerHTML = `<span class="channel-compose-bar__text">${state.session.isAuthorized ? 'Написать ответ…' : 'Войти и ответить'}</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
reply.addEventListener('click', () => {
|
||||
const parsed = parseMessageAttachments(resolveNodeText(focus));
|
||||
openReplyModal({
|
||||
@@ -1448,7 +1463,7 @@ export function render({ navigate, route, chrome }) {
|
||||
descendantsWrap.append(renderDescendants(descendants, handlers, nextNumber));
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет ответов. Начните обсуждение.';
|
||||
descendantsWrap.append(empty);
|
||||
}
|
||||
@@ -1478,7 +1493,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (hadContent) { showStatus(toUserMessage(error, 'Не удалось обновить обсуждение.')); return; }
|
||||
const failed = document.createElement('div');
|
||||
failed.className = 'card meta-muted';
|
||||
failed.textContent = `Не удалось загрузить тред: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
failed.textContent = `Не удалось загрузить обсуждение: ${toUserMessage(error, 'неизвестная ошибка')}`;
|
||||
const retry = document.createElement('button');
|
||||
retry.type = 'button';
|
||||
retry.className = 'primary-btn';
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { rememberChannelPosition, readChannelPosition, restoreChannelPosition } from '../services/channel-view-state.js';
|
||||
import { getPreviousTrackedPath } from '../router.js';
|
||||
import { attachMessageMenu } from '../components/message-menu.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
@@ -13,6 +15,9 @@ import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
dayKey,
|
||||
formatClockTime,
|
||||
formatDayLabel,
|
||||
formatRelativeTime,
|
||||
longPressFeel,
|
||||
shareOrCopyLink,
|
||||
@@ -39,7 +44,7 @@ import {
|
||||
} from '../services/shine-routes.js';
|
||||
import { parseDmTechBlocks } from '../services/dm-tech-blocks.js';
|
||||
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
export const pageMeta = { id: 'channel-view', title: 'Канал', hideToolbar: true, shellMode: { scrollbar: 'hidden', topFade: false, contentUnderTopbar: false, contentWidth: 'wide' } };
|
||||
const CHANNEL_TYPE_STORIES = 0;
|
||||
const CHANNEL_TYPE_PERSONAL = 100;
|
||||
const MSG_SUBTYPE_TEXT_RATING = 30;
|
||||
@@ -828,7 +833,7 @@ function openBlockchainDetailsModal(details, { isActive = () => true } = {}) {
|
||||
<div class="modal" id="blockchain-details-modal">
|
||||
<div class="modal-card stack blockchain-details-card">
|
||||
<h3 class="modal-title">Данные блокчейна сообщения</h3>
|
||||
<p class="meta-muted">Это технические данные записи SHiNE. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<p class="meta-muted">Это технические данные записи Сияния. По ним можно увидеть цепочку автора, номер записи, хэш и подпись, если она есть в ответе сервера.</p>
|
||||
<div class="blockchain-details-grid">
|
||||
<span>Автор</span><strong>${escapeHtml(details.authorLogin)}</strong>
|
||||
<span>Блокчейн</span><code>${escapeHtml(details.authorBlockchainName)}</code>
|
||||
@@ -874,8 +879,8 @@ function renderDraftAttachments(container, attachments) {
|
||||
button.className = 'ui-button draft-attachment-chip';
|
||||
button.textContent = `${item.name} · ${item.ar}`;
|
||||
button.title = 'Нажмите, чтобы убрать вложение';
|
||||
button.addEventListener('click', () => {
|
||||
const ok = window.confirm('Отменить вложение?');
|
||||
button.addEventListener('click', async () => {
|
||||
const ok = await confirmDialog({ title: 'Убрать вложение?', text: item.name, confirmLabel: 'Убрать', danger: true });
|
||||
if (!ok) return;
|
||||
attachments.splice(index, 1);
|
||||
renderDraftAttachments(container, attachments);
|
||||
@@ -1063,7 +1068,7 @@ function openEntrypointHistoryModal({ channelTitle = '', posts = [], onSelect })
|
||||
if (list) {
|
||||
if (!items.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Оглавление в этом канале пока не добавлялось.';
|
||||
list.append(empty);
|
||||
} else {
|
||||
@@ -1169,18 +1174,19 @@ function openRepostModal({ navigate, channels = [], onSubmit, isActive = () => t
|
||||
function openAddMessageModal({ channelName, onSubmit, navigate, isActive = () => true }) {
|
||||
const typeWrap = document.createElement('label');
|
||||
typeWrap.className = 'channel-editor__type';
|
||||
typeWrap.textContent = 'Тип сообщения';
|
||||
typeWrap.innerHTML = '<span class="sr-only">Тип сообщения</span>';
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'select';
|
||||
typeSelect.className = 'select channel-editor__type-select';
|
||||
typeSelect.title = 'Тип сообщения';
|
||||
typeSelect.innerHTML = `
|
||||
<option value="10">Публикация</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление канала</option>
|
||||
<option value="${MSG_SUBTYPE_TEXT_ENTRYPOINT}">Оглавление</option>
|
||||
`;
|
||||
typeWrap.append(typeSelect);
|
||||
|
||||
return openChannelEditor({
|
||||
id: 'channel-message-modal',
|
||||
title: 'Новое сообщение',
|
||||
title: 'Новая запись',
|
||||
submitLabel: 'Опубликовать',
|
||||
placeholder: 'Напишите сообщение',
|
||||
key: `channel-post:${channelName}`,
|
||||
@@ -1611,14 +1617,15 @@ function mapChannelMetaEvent(event, fallbackChannel) {
|
||||
};
|
||||
}
|
||||
|
||||
function renderChannelMetaEventCard(event) {
|
||||
function renderChannelMetaEventCard(event, dayLabel = '') {
|
||||
const card = document.createElement('article');
|
||||
card.className = 'card channel-system-event-card';
|
||||
const label = event.kind === 'created'
|
||||
? 'Создан канал'
|
||||
? 'Канал создан'
|
||||
: 'Изменено описание канала';
|
||||
const shownLabel = dayLabel ? `${label} · ${dayLabel.toLocaleLowerCase('ru-RU')}` : label;
|
||||
card.innerHTML = `
|
||||
<span class="channel-system-event-card__label">${escapeHtml(label)}</span>
|
||||
<span class="channel-system-event-card__label">${escapeHtml(shownLabel)}</span>
|
||||
`;
|
||||
card.addEventListener('click', () => {
|
||||
openChannelMetaDetailsModal({
|
||||
@@ -1856,6 +1863,7 @@ function renderPostCard(post, {
|
||||
const timestamp = document.createElement('div');
|
||||
timestamp.className = 'channel-message-time';
|
||||
timestamp.textContent = post.timestampMs ? formatRelativeTime(post.timestampMs) : '—';
|
||||
if (post.timestampMs) timestamp.title = new Date(post.timestampMs).toLocaleString('ru-RU');
|
||||
if (versionsTotal > 1) {
|
||||
const editedMarker = document.createElement('span');
|
||||
editedMarker.type = 'button';
|
||||
@@ -1906,6 +1914,15 @@ function renderPostCard(post, {
|
||||
navigate(makeProfileRoute(cleanLogin));
|
||||
});
|
||||
|
||||
const isOwnerPost = !!selector
|
||||
&& !isDiarySelector(selector)
|
||||
&& !!post.messageRef?.blockchainName
|
||||
&& String(post.messageRef.blockchainName).trim().toLowerCase() === String(selector.ownerBlockchainName || '').trim().toLowerCase();
|
||||
if (isOwnerPost) {
|
||||
card.classList.add('channel-message-card--owner');
|
||||
authorTile.hidden = true;
|
||||
}
|
||||
|
||||
const isDeletedMessage = String(post.body || '').trim().toLowerCase() === 'удалено';
|
||||
const parsedBody = parseMessageAttachments(post.body);
|
||||
|
||||
@@ -1990,7 +2007,7 @@ function renderPostCard(post, {
|
||||
likeButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('heart', isLiked)}</span>
|
||||
<span class="channel-action-label">${isPending ? 'Лайк...' : 'Лайк'}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.likesCount || ''}</span>
|
||||
`;
|
||||
likeButton.setAttribute('aria-pressed', String(isLiked));
|
||||
setActionTitle(likeButton, isPending ? 'Лайк…' : `${isLiked ? 'Убрать отметку «Нравится»' : 'Нравится'}, ${post.likesCount || 0}`);
|
||||
@@ -2015,7 +2032,7 @@ function renderPostCard(post, {
|
||||
discussionButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Обсуждение</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(discussionButton, `Открыть обсуждение, ответов: ${post.repliesCount || 0}`);
|
||||
discussionButton.addEventListener('click', (event) => {
|
||||
@@ -2030,7 +2047,7 @@ function renderPostCard(post, {
|
||||
replyButton.innerHTML = `
|
||||
<span class="channel-action-icon" aria-hidden="true">${iconHtml('message')}</span>
|
||||
<span class="channel-action-label">Ответить</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || 0}</span>
|
||||
<span class="channel-action-counter">${post.repliesCount || ''}</span>
|
||||
`;
|
||||
setActionTitle(replyButton, 'Ответить');
|
||||
replyButton.addEventListener('click', (event) => {
|
||||
@@ -2067,7 +2084,8 @@ function renderPostCard(post, {
|
||||
await onShare(route);
|
||||
});
|
||||
|
||||
actions.append(shareButton, replyButton);
|
||||
actions.append(shareButton);
|
||||
menuItems.unshift({ label: 'Ответить', action: () => replyButton.click() });
|
||||
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';
|
||||
@@ -2081,8 +2099,6 @@ function renderPostCard(post, {
|
||||
event.stopPropagation();
|
||||
const ownerLogin = extractLoginFromBlockchainName(post.targetRef.blockchainName);
|
||||
if (!ownerLogin) return;
|
||||
const ok = window.confirm('Перейти к оригинальному сообщению?');
|
||||
if (!ok) return;
|
||||
navigate(makeShineMessageRoute({
|
||||
ownerLogin,
|
||||
messageBlockchainName: post.targetRef.blockchainName,
|
||||
@@ -2135,13 +2151,28 @@ function renderPostCard(post, {
|
||||
});
|
||||
menuItems.unshift({ label: 'Редактировать', action: () => editButton.click() });
|
||||
menuItems.push({ label: 'Удалить', danger: true, action: async () => {
|
||||
if (!window.confirm('Скрыть сообщение из ленты? Предыдущие версии останутся в блокчейне и истории изменений.')) return;
|
||||
if (!await confirmDialog({
|
||||
title: 'Удалить сообщение?',
|
||||
text: 'Сообщение скроется из ленты. Предыдущие версии останутся в блокчейне и в истории изменений.',
|
||||
confirmLabel: 'Удалить',
|
||||
danger: true,
|
||||
})) return;
|
||||
try { await onEdit(post.messageRef, '', { isDelete: true }); }
|
||||
catch (error) { if (isActive()) showToast(toUserMessage(error, 'Не удалось удалить сообщение.')); }
|
||||
} });
|
||||
}
|
||||
if (versionsTotal > 1) menuItems.push({ label: 'История изменений', action: () => openMessageHistoryModal({ versions: post.versions }) });
|
||||
attachMessageMenu(card, headRow, menuItems);
|
||||
if (isOwnerPost) {
|
||||
if (post.timestampMs) timestamp.textContent = formatClockTime(post.timestampMs);
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'channel-message-meta';
|
||||
const moreButton = headRow.querySelector('.channel-message-more');
|
||||
meta.append(timestamp);
|
||||
if (moreButton) meta.append(moreButton);
|
||||
actions.append(meta);
|
||||
if (!headRow.querySelector('.channel-message-type-button')) headRow.hidden = true;
|
||||
}
|
||||
card.append(actions);
|
||||
return card;
|
||||
}
|
||||
@@ -2166,8 +2197,9 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
|
||||
const addMessageButton = document.createElement('button');
|
||||
addMessageButton.type = 'button';
|
||||
addMessageButton.className = 'primary-btn channel-main-action channel-main-action--compose';
|
||||
addMessageButton.textContent = 'Добавить сообщение';
|
||||
addMessageButton.className = 'channel-compose-bar';
|
||||
addMessageButton.setAttribute('aria-label', 'Написать в канал');
|
||||
addMessageButton.innerHTML = `<span class="channel-compose-bar__text">Написать в канал…</span><span class="channel-compose-bar__icon" aria-hidden="true">${iconHtml('plus')}</span>`;
|
||||
addMessageButton.addEventListener('click', (event) => {
|
||||
animatePress(event.currentTarget);
|
||||
handlers.onAddMessage();
|
||||
@@ -2199,7 +2231,17 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
|
||||
if (feedItems.length) {
|
||||
let lastDayKey = '';
|
||||
feedItems.forEach((item) => {
|
||||
const itemDayKey = dayKey(item.timestampMs);
|
||||
const startsNewDay = !!itemDayKey && itemDayKey !== lastDayKey;
|
||||
if (startsNewDay) lastDayKey = itemDayKey;
|
||||
if (startsNewDay && item.type !== 'meta') {
|
||||
const separator = document.createElement('div');
|
||||
separator.className = 'channel-day-separator';
|
||||
separator.innerHTML = `<span>${escapeHtml(formatDayLabel(item.timestampMs))}</span>`;
|
||||
feed.append(separator);
|
||||
}
|
||||
if (!unreadLineInserted && item.type === 'post' && Number(item?.post?.localNumber || 0) > readCount) {
|
||||
const unreadLine = document.createElement('div');
|
||||
unreadLine.className = 'card channel-unread-line';
|
||||
@@ -2208,7 +2250,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
unreadLineInserted = true;
|
||||
}
|
||||
if (item.type === 'meta') {
|
||||
feed.append(renderChannelMetaEventCard(item.event));
|
||||
feed.append(renderChannelMetaEventCard(item.event, startsNewDay ? formatDayLabel(item.timestampMs) : ''));
|
||||
return;
|
||||
}
|
||||
const row = renderPostCard(item.post, {
|
||||
@@ -2232,7 +2274,7 @@ function renderBody(screen, navigate, routeKey, channelData, handlers) {
|
||||
});
|
||||
} else {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = channelData.isDiary
|
||||
? 'К сожалению, у вас пока еще ничего нет в Дневнике.'
|
||||
: 'Ждем ваших начинаний';
|
||||
@@ -2400,26 +2442,6 @@ export function render({ navigate, route, chrome }) {
|
||||
const aboutRoute = makeShineChannelAboutRoute(routeArgs);
|
||||
const donateRoute = makeShineChannelDonateRoute(routeArgs);
|
||||
const items = [];
|
||||
if (apiData?.isOwnChannel && !apiData?.isDiary) {
|
||||
items.push({
|
||||
label: 'Добавить сообщение',
|
||||
action: () => {
|
||||
openAddMessageModal({
|
||||
channelName: apiData?.channel?.name || '',
|
||||
navigate,
|
||||
isActive: () => !disposed,
|
||||
onSubmit: async ({ text: bodyText, msgSubType }) => {
|
||||
try {
|
||||
await onAddPost(bodyText, msgSubType);
|
||||
showStatus('');
|
||||
} catch (error) {
|
||||
throw new Error(toUserMessage(error, 'Не удалось добавить сообщение.'));
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
items.push({ label: 'О канале', action: () => { if (aboutRoute) navigate(aboutRoute); } });
|
||||
items.push({
|
||||
label: 'Оглавление',
|
||||
@@ -2479,9 +2501,6 @@ export function render({ navigate, route, chrome }) {
|
||||
try {
|
||||
const { login, storagePwd } = requireSigningSession();
|
||||
if (!apiData?.selector) throw new Error('Не удалось определить канал для подписки.');
|
||||
const targetName = `${apiData.channel?.ownerName || 'user'}/${apiData.channel?.name || 'channel'}`;
|
||||
const ok = window.confirm(`Подписаться на канал ${targetName}?`);
|
||||
if (!ok) return;
|
||||
|
||||
await authService.addBlockFollowChannel({
|
||||
login,
|
||||
@@ -2694,7 +2713,7 @@ export function render({ navigate, route, chrome }) {
|
||||
if (!routeToShare) throw new Error('Не удалось подготовить ссылку на сообщение.');
|
||||
const result = await shareOrCopyLink({
|
||||
title: 'SHiNE · Каналы',
|
||||
text: 'Тред из канала SHiNE',
|
||||
text: 'Обсуждение в Сиянии',
|
||||
url: buildAbsoluteRouteUrl(routeToShare),
|
||||
});
|
||||
if (disposed) return;
|
||||
@@ -2806,7 +2825,13 @@ export function render({ navigate, route, chrome }) {
|
||||
if (disposed) return;
|
||||
const seq = ++refreshSeq;
|
||||
const hadContent = !!screen.querySelector('.channel-feed');
|
||||
const restorePosition = hadContent ? getChannelScrollRoot()?.scrollTop : readChannelPosition(positionKey);
|
||||
// Прежнее место в ленте восстанавливаем только при возврате из обсуждения / «О канале».
|
||||
// Открытие канала из списка или другого раздела ведёт к свежим постам (или к непрочитанным).
|
||||
const previousPath = getPreviousTrackedPath() || '';
|
||||
const returnedFromChannelPage = /^\/(SHiNE|thread)\//.test(previousPath) || /\/(about|donate)$/.test(previousPath);
|
||||
const restorePosition = hadContent
|
||||
? getChannelScrollRoot()?.scrollTop
|
||||
: (returnedFromChannelPage ? readChannelPosition(positionKey) : undefined);
|
||||
if (!hadContent) clearContent();
|
||||
activeChannelData = null;
|
||||
activeOpenEntrypointHistory = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { channels as mockChannels } from '../mock-data.js';
|
||||
import { authService, setChannelsFeed, state } from '../state.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
@@ -5,7 +6,7 @@ import { parseMessageAttachments } from '../services/attachment-format.js';
|
||||
import {
|
||||
animatePress,
|
||||
createSkeletonCard,
|
||||
formatRelativeTime,
|
||||
formatListTime,
|
||||
readChannelNotificationsState,
|
||||
showToast,
|
||||
softHaptic,
|
||||
@@ -755,7 +756,7 @@ function mapApiChannelRow(summary, bucketKey, idx, index, notificationsState) {
|
||||
messagePreview: cleanChannelMessagePreview(lastMessage.text),
|
||||
messagesCount: Number(summary?.messagesCount || 0),
|
||||
unreadCount: Number(summary?.unreadCount || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || 0),
|
||||
lastMessageAt: Number(lastMessage.createdAtMs || summary?.channel?.metaUpdatedAtMs || 0),
|
||||
isOwnChannel: isOwn,
|
||||
isSubscribed: !isOwn,
|
||||
notificationsEnabled: notificationsState[rowId] === true,
|
||||
@@ -964,12 +965,13 @@ function renderChannelMain(channel) {
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'channel-row-time';
|
||||
time.textContent = channel.lastMessageAt ? formatRelativeTime(channel.lastMessageAt) : '';
|
||||
time.textContent = channel.lastMessageAt ? formatListTime(channel.lastMessageAt) : '';
|
||||
titleLine.append(title, time);
|
||||
|
||||
const technical = document.createElement('p');
|
||||
technical.className = 'channel-row-technical';
|
||||
technical.textContent = `@${channel.ownerName || 'автор'}`;
|
||||
technical.hidden = !!channel.isOwnChannel;
|
||||
|
||||
if (channel.channelDescription) {
|
||||
const desc = document.createElement('p');
|
||||
@@ -984,6 +986,7 @@ function renderChannelMain(channel) {
|
||||
const preview = document.createElement('p');
|
||||
preview.className = 'channel-row-message';
|
||||
preview.textContent = channel.messagePreview || 'Пока нет сообщений';
|
||||
if (!channel.messagePreview) preview.classList.add('is-empty');
|
||||
|
||||
previewLine.append(preview);
|
||||
|
||||
@@ -1173,7 +1176,7 @@ export function render({ navigate, route, chrome }) {
|
||||
controls.className = 'channels-list-controls';
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'channels-inline-search';
|
||||
searchWrap.innerHTML = '<span aria-hidden="true">⌕</span><span class="sr-only">Поиск каналов</span>';
|
||||
searchWrap.innerHTML = `<span class="channels-inline-search__icon" aria-hidden="true">${iconHtml('search')}</span><span class="sr-only">Поиск каналов</span>`;
|
||||
const searchInput = document.createElement('input');
|
||||
searchInput.type = 'search';
|
||||
searchInput.value = listState.query;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
stopAllTwemojiAnimations,
|
||||
} from '../components/emoji-picker.js?v=202607152130';
|
||||
import { isTextToSpeechConfigured, speakTextBySettings } from '../services/speech-tools-service.js';
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { dayKey, formatClockTime, formatDayLabel, showToast } from '../services/channels-ux.js';
|
||||
import { buildDmFileTechBlock, buildDmReplyTechBlock, parseDmTechBlocks, sanitizeUserDmTextForSend } from '../services/dm-tech-blocks.js';
|
||||
import { isDmFileTransferEnabled } from '../services/feature-settings.js';
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ import { makeProfileLinksRoute, makeProfileRoute } from '../services/shine-route
|
||||
export const pageMeta = {
|
||||
id: 'chat-view',
|
||||
title: 'Чат',
|
||||
hideToolbar: true,
|
||||
shellMode: {
|
||||
topFade: true,
|
||||
bottomFade: true,
|
||||
@@ -81,7 +82,7 @@ function chatRelationLabel(value) {
|
||||
case 'close_friend': return 'Близкий друг';
|
||||
case 'friend': return 'Друг';
|
||||
case 'contact': return 'Контакт';
|
||||
default: return 'Не в контактах';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +118,9 @@ function createChatHeaderParts(login, navigate) {
|
||||
const lastName = String(currentPeer?.lastName || '').trim();
|
||||
const fullName = [firstName, lastName].filter(Boolean).join(' ');
|
||||
nameEl.textContent = fullName || cleanLogin;
|
||||
metaEl.textContent = `${cleanLogin} · ${chatRelationLabel(currentPeer?.relationType)}`;
|
||||
const relation = chatRelationLabel(currentPeer?.relationType);
|
||||
metaEl.textContent = [fullName ? `@${cleanLogin}` : '', relation].filter(Boolean).join(' · ');
|
||||
metaEl.hidden = !metaEl.textContent;
|
||||
const avatar = renderUserAvatar({
|
||||
login: cleanLogin,
|
||||
firstName,
|
||||
@@ -1058,7 +1061,19 @@ function renderLog(
|
||||
const messages = getChatMessages(chatId);
|
||||
let unreadSeparatorInserted = false;
|
||||
const separatorMessageKey = String(unreadSeparatorMessageKey || '').trim();
|
||||
let lastDayKey = '';
|
||||
messages.forEach((msg) => {
|
||||
const msgTimeMs = resolveMessageTimeMs(msg);
|
||||
const msgDayKey = dayKey(msgTimeMs);
|
||||
if (msgDayKey && msgDayKey !== lastDayKey) {
|
||||
lastDayKey = msgDayKey;
|
||||
const daySep = document.createElement('div');
|
||||
daySep.className = 'dm-day-separator';
|
||||
const dayLabel = document.createElement('span');
|
||||
dayLabel.textContent = formatDayLabel(msgTimeMs);
|
||||
daySep.append(dayLabel);
|
||||
list.append(daySep);
|
||||
}
|
||||
const isUnreadBoundary = showUnreadSeparator
|
||||
&& !unreadSeparatorInserted
|
||||
&& separatorMessageKey
|
||||
@@ -1176,7 +1191,8 @@ function renderLog(
|
||||
|
||||
const timeNode = document.createElement('span');
|
||||
timeNode.className = 'bubble-time';
|
||||
timeNode.textContent = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
timeNode.textContent = formatClockTime(resolveMessageTimeMs(msg));
|
||||
timeNode.title = formatMessageTime(resolveMessageTimeMs(msg));
|
||||
metaNode.append(timeNode);
|
||||
|
||||
const status = resolveDeliveryStatus(messages, msg);
|
||||
@@ -1812,7 +1828,7 @@ export function render({ navigate, route, chrome }) {
|
||||
updateSendButtonMode();
|
||||
const denied = error?.name === 'NotAllowedError' || error?.name === 'SecurityError';
|
||||
showToast(
|
||||
denied ? 'Нужно разрешить SHiNE доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
denied ? 'Нужно разрешить Сиянию доступ к микрофону.' : (error?.message || 'Не удалось начать запись'),
|
||||
{ kind: 'error', timeoutMs: 2600 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { iconHtml } from '../components/ui-icon.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { authService } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
@@ -65,15 +66,17 @@ export function render({navigate, chrome}) {
|
||||
let searchSeq = 0;
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'input dm-input contact-search-input';
|
||||
input.className = 'search-field__input contact-search-input';
|
||||
input.type = 'text';
|
||||
input.name = 'contact';
|
||||
input.placeholder = 'Введите начало логина';
|
||||
input.placeholder = 'Логин или его начало';
|
||||
input.setAttribute('aria-label', 'Логин для поиска');
|
||||
input.enterKeyHint = 'search';
|
||||
input.autocomplete = 'off';
|
||||
input.maxLength = 80;
|
||||
|
||||
const resultsCard = document.createElement('section');
|
||||
resultsCard.className = 'card stack contact-search-results-card';
|
||||
resultsCard.className = 'stack contact-search-results';
|
||||
resultsCard.hidden = true;
|
||||
|
||||
const status = document.createElement('p');
|
||||
@@ -143,13 +146,13 @@ export function render({navigate, chrome}) {
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchTimer = 0;
|
||||
void runSearch();
|
||||
}, 2000);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const searchButton = document.createElement('button');
|
||||
searchButton.className = 'primary-btn dm-send-btn';
|
||||
searchButton.className = 'search-field__action';
|
||||
searchButton.type = 'button';
|
||||
searchButton.textContent = 'Поиск';
|
||||
searchButton.textContent = 'Найти';
|
||||
searchButton.addEventListener('click', async () => {
|
||||
if (searchTimer) {
|
||||
window.clearTimeout(searchTimer);
|
||||
@@ -161,19 +164,21 @@ export function render({navigate, chrome}) {
|
||||
input.addEventListener('input', () => {
|
||||
scheduleSearch();
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
searchButton.click();
|
||||
});
|
||||
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'contact-search-actions';
|
||||
controls.append(searchButton);
|
||||
|
||||
const formCard = document.createElement('section');
|
||||
formCard.className = 'card stack contact-search-form-card';
|
||||
formCard.append(input, controls);
|
||||
const formCard = document.createElement('label');
|
||||
formCard.className = 'search-field contact-search-field';
|
||||
formCard.innerHTML = `<span class="search-field__icon" aria-hidden="true">${iconHtml('search')}</span>`;
|
||||
formCard.append(input, searchButton);
|
||||
|
||||
resultsCard.append(status, resultsList);
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Поиск контактов',
|
||||
title: 'Найти человека',
|
||||
back: { label: '←', onClick: () => navigate('messages-list') },
|
||||
}));
|
||||
screen.append(
|
||||
|
||||
@@ -207,7 +207,7 @@ async function forceUiUpdateNow() {
|
||||
function showClientUpdateHelp() {
|
||||
window.alert(
|
||||
'Если UI не обновился:\n\n'
|
||||
+ '1) Закройте вкладки с SHiNE.\n'
|
||||
+ '1) Закройте вкладки с Сиянием.\n'
|
||||
+ `2) Откройте chrome://settings/siteData и удалите данные для ${defaultServerAddress}.\n`
|
||||
+ '3) Если приложение установлено как PWA — удалите его с устройства.\n'
|
||||
+ `4) Откройте ${defaultServerHttp} заново и выполните вход.\n`
|
||||
@@ -256,17 +256,17 @@ export function render({navigate, chrome}) {
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack settings-developer-card';
|
||||
card.className = 'nav-list settings-developer-card';
|
||||
card.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-ui-update">Принудительно обновить UI</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-force-update-help">Клиент не обновляется?</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-ui-error-reporting">Отправлять ошибки на сервер</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-users-init">Solana: init регистрации</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-solana-rpc-check">Solana: проверить public RPC</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-app-log">Лог приложения</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-diagnostics">Диагностика PWA / Push</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-pwa-install">Как установить PWA</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-upload-avatar">Загрузить аватар</button>
|
||||
<button class="nav-row" type="button" id="settings-force-ui-update"><span class="nav-row__label">Принудительно обновить UI</span></button>
|
||||
<button class="nav-row" type="button" id="settings-force-update-help"><span class="nav-row__label">Клиент не обновляется?</span></button>
|
||||
<button class="nav-row" type="button" id="settings-ui-error-reporting"><span class="nav-row__label">Отправлять ошибки на сервер</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-users-init"><span class="nav-row__label">Solana: первичная настройка регистрации</span></button>
|
||||
<button class="nav-row" type="button" id="settings-solana-rpc-check"><span class="nav-row__label">Solana: проверить публичный узел</span></button>
|
||||
<button class="nav-row" type="button" id="settings-app-log"><span class="nav-row__label">Лог приложения</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-diagnostics"><span class="nav-row__label">Диагностика PWA / Push</span></button>
|
||||
<button class="nav-row" type="button" id="settings-pwa-install"><span class="nav-row__label">Установка на главный экран</span></button>
|
||||
<button class="nav-row" type="button" id="settings-upload-avatar"><span class="nav-row__label">Загрузить аватар</span></button>
|
||||
`;
|
||||
|
||||
const appLogBtn = card.querySelector('#settings-app-log');
|
||||
@@ -307,16 +307,22 @@ export function render({navigate, chrome}) {
|
||||
}
|
||||
});
|
||||
|
||||
// Меняем только подпись строки: textContent на самой кнопке стирал .nav-row__label, и строка выбивалась по кеглю.
|
||||
const setPwaLabel = (text) => {
|
||||
const labelEl = pwaInstallBtn.querySelector('.nav-row__label');
|
||||
if (labelEl) labelEl.textContent = text;
|
||||
else pwaInstallBtn.textContent = text;
|
||||
};
|
||||
const syncPwaButtonLabel = () => {
|
||||
if (isStandalonePwaMode()) {
|
||||
pwaInstallBtn.textContent = 'PWA установлено (проверить WebPush)';
|
||||
setPwaLabel('Приложение установлено — проверить уведомления');
|
||||
return;
|
||||
}
|
||||
if (canInstallPwa()) {
|
||||
pwaInstallBtn.textContent = 'Зарегистрировать PWA';
|
||||
setPwaLabel('Установить как приложение');
|
||||
return;
|
||||
}
|
||||
pwaInstallBtn.textContent = 'Как установить PWA';
|
||||
setPwaLabel('Установка на главный экран');
|
||||
};
|
||||
|
||||
const unsubscribeInstallAvailability = onPwaInstallAvailabilityChange(() => {
|
||||
|
||||
@@ -250,7 +250,7 @@ export function render({navigate, chrome}) {
|
||||
passwordDialog.style.inset = '0';
|
||||
passwordDialog.style.zIndex = '30';
|
||||
passwordDialog.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<div class="stack" style="gap:6px;">
|
||||
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
|
||||
@@ -292,7 +292,7 @@ export function render({navigate, chrome}) {
|
||||
dialogOverlay.style.inset = '0';
|
||||
dialogOverlay.style.zIndex = '2';
|
||||
dialogOverlay.innerHTML = `
|
||||
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
|
||||
<div style="position:absolute; inset:0; background:var(--scrim);"></div>
|
||||
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
|
||||
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
|
||||
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { showToast } from '../services/channels-ux.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
isSessionInvalidError,
|
||||
@@ -11,24 +12,49 @@ import {
|
||||
export const pageMeta = { id: 'device-view', title: 'Устройства' };
|
||||
|
||||
function formatSessionType(sessionType) {
|
||||
if (Number(sessionType) === 100) return 'Homeserver';
|
||||
if (Number(sessionType) === 50) return 'Wallet';
|
||||
if (Number(sessionType) === 1) return 'Client';
|
||||
return `Type ${Number(sessionType) || 0}`;
|
||||
if (Number(sessionType) === 100) return 'Домашний сервер';
|
||||
if (Number(sessionType) === 50) return 'Кошелёк';
|
||||
if (Number(sessionType) === 1) return 'Приложение';
|
||||
return `Тип ${Number(sessionType) || 0}`;
|
||||
}
|
||||
|
||||
// «Mozilla/5.0 (X11; Linux x86_64) … Chrome/139 …» → «Chrome · Linux».
|
||||
function describeClient(userAgent) {
|
||||
const ua = String(userAgent || '');
|
||||
if (!ua) return 'Неизвестное устройство';
|
||||
const browser = /Edg\//.test(ua) ? 'Edge'
|
||||
: /OPR\/|Opera/.test(ua) ? 'Opera'
|
||||
: /Firefox\//.test(ua) ? 'Firefox'
|
||||
: /YaBrowser\//.test(ua) ? 'Яндекс Браузер'
|
||||
: /Chrome\//.test(ua) ? 'Chrome'
|
||||
: /Safari\//.test(ua) ? 'Safari'
|
||||
: '';
|
||||
const os = /iPhone/.test(ua) ? 'iPhone'
|
||||
: /iPad/.test(ua) ? 'iPad'
|
||||
: /Android/.test(ua) ? 'Android'
|
||||
: /Windows/.test(ua) ? 'Windows'
|
||||
: /Mac OS X|Macintosh/.test(ua) ? 'macOS'
|
||||
: /Linux|X11/.test(ua) ? 'Linux'
|
||||
: '';
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length ? parts.join(' · ') : ua.slice(0, 40);
|
||||
}
|
||||
|
||||
// «сегодня, 02:20» / «вчера, 23:10» / «24 сентября, 18:05».
|
||||
function formatSessionTime(ms) {
|
||||
return new Date(ms).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
const date = new Date(ms);
|
||||
const time = date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const days = Math.round((startOf(new Date()) - startOf(date)) / 86400000);
|
||||
if (days === 0) return `сегодня, ${time}`;
|
||||
if (days === 1) return `вчера, ${time}`;
|
||||
const sameYear = date.getFullYear() === new Date().getFullYear();
|
||||
const day = date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) });
|
||||
return `${day}, ${time}`;
|
||||
}
|
||||
|
||||
function formatOnlineStatus(onlineOnThisServer) {
|
||||
return onlineOnThisServer ? 'Online now' : 'Offline';
|
||||
return onlineOnThisServer ? 'в сети' : 'не в сети';
|
||||
}
|
||||
|
||||
function sortSessionsByOnline(sessions = []) {
|
||||
@@ -47,15 +73,15 @@ export function render({navigate, chrome}) {
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Устройства',
|
||||
back: { label: '←', onClick: () => navigate('settings-view') },
|
||||
actions: [{ icon: 'refresh', title: 'Обновить список сеансов', ariaLabel: 'Обновить список сеансов', id: 'reload-sessions', onClick: () => reloadSessions() }],
|
||||
}));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'card stack';
|
||||
actions.className = 'nav-list';
|
||||
actions.innerHTML = `
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="reload-sessions-btn">Обновить сессии</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="pair-by-code-btn">Подключить по коду</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="connect-device-btn">Другие способы подключения</button>
|
||||
<button class="shine-btn shine-btn--settings settings-bordered-btn" type="button" id="show-keys-btn">Показать ключи</button>
|
||||
<button class="nav-row" type="button" id="pair-by-code-btn"><span class="nav-row__label">Подключить по коду</span><span class="nav-row__hint">Войти на другом устройстве по короткому коду</span></button>
|
||||
<button class="nav-row" type="button" id="connect-device-btn"><span class="nav-row__label">Другие способы подключения</span></button>
|
||||
<button class="nav-row" type="button" id="show-keys-btn"><span class="nav-row__label">Ключи этого устройства</span></button>
|
||||
`;
|
||||
|
||||
actions.querySelector('#pair-by-code-btn').addEventListener('click', () => navigate('device-pairing-view'));
|
||||
@@ -63,7 +89,7 @@ export function render({navigate, chrome}) {
|
||||
actions.querySelector('#show-keys-btn').addEventListener('click', () => navigate('show-keys-view'));
|
||||
|
||||
const sessionsBlock = document.createElement('div');
|
||||
sessionsBlock.className = 'card stack';
|
||||
sessionsBlock.className = 'stack device-sessions';
|
||||
|
||||
const buildList = () => {
|
||||
sessionsBlock.innerHTML = '';
|
||||
@@ -75,22 +101,25 @@ export function render({navigate, chrome}) {
|
||||
const item = document.createElement('button');
|
||||
item.className = 'ui-button session-item';
|
||||
item.type = 'button';
|
||||
const sessionTypeText = formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const isWebClient = Number(session.sessionType) === 1 && /web/i.test(String(session.clientPlatform || ''));
|
||||
const sessionTypeText = isWebClient ? 'Браузер' : formatSessionType(session.sessionType);
|
||||
const sessionPlatformText = !isWebClient && session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
||||
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
||||
item.innerHTML = `
|
||||
<div class="row" style="align-items:flex-start;">
|
||||
<div class="stack" style="gap:4px; text-align:left;">
|
||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
||||
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
|
||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||
</div>
|
||||
<span class="session-item__main">
|
||||
<strong class="session-item__title"></strong>
|
||||
<span class="meta-muted">${sessionTypeText}${sessionPlatformText} · <span class="${onlineStatusClass}">${onlineStatusText}</span></span>
|
||||
${session.geo && session.geo !== 'unknown' ? '<span class="meta-muted session-item__geo"></span>' : ''}
|
||||
</span>
|
||||
<span class="session-item__side">
|
||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||
</div>
|
||||
${isCurrent ? '<div><span class="session-current-badge">Текущий сеанс</span></div>' : ''}
|
||||
</span>
|
||||
`;
|
||||
item.querySelector('.session-item__title').textContent = describeClient(session.clientInfoFromClient);
|
||||
item.title = String(session.clientInfoFromClient || '');
|
||||
const geoEl = item.querySelector('.session-item__geo');
|
||||
if (geoEl) geoEl.textContent = session.geo;
|
||||
item.addEventListener('click', () => navigate(`device-session-view/${session.sessionId}`));
|
||||
return item;
|
||||
};
|
||||
@@ -103,60 +132,36 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMenu = document.createElement('div');
|
||||
currentMenu.className = 'stack';
|
||||
currentMenu.innerHTML = '<p class="meta-muted">Текущий сеанс</p>';
|
||||
currentMenu.append(createSessionItem(current, true));
|
||||
|
||||
const endCurrentSessionBtn = document.createElement('button');
|
||||
endCurrentSessionBtn.className = 'shine-btn shine-btn--settings settings-bordered-btn';
|
||||
endCurrentSessionBtn.type = 'button';
|
||||
endCurrentSessionBtn.textContent = 'Завершить текущую сессию';
|
||||
endCurrentSessionBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm('Хотите завершить текущую сессию?');
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
closeServerSession: true,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isSessionInvalidError(error)) {
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
return;
|
||||
}
|
||||
await terminateCurrentSession({
|
||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||
});
|
||||
}
|
||||
});
|
||||
currentMenu.append(endCurrentSessionBtn);
|
||||
|
||||
const othersMenu = document.createElement('div');
|
||||
othersMenu.className = 'stack';
|
||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
|
||||
const groupTitle = (text) => {
|
||||
const title = document.createElement('p');
|
||||
title.className = 'nav-list__title';
|
||||
title.textContent = text;
|
||||
return title;
|
||||
};
|
||||
const currentList = document.createElement('div');
|
||||
currentList.className = 'nav-list';
|
||||
currentList.append(createSessionItem(current, true));
|
||||
sessionsBlock.append(groupTitle('Это устройство'), currentList, groupTitle('Другие сеансы'));
|
||||
|
||||
if (others.length === 0) {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'meta-muted';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Других сеансов нет.';
|
||||
othersMenu.append(empty);
|
||||
sessionsBlock.append(empty);
|
||||
} else {
|
||||
others.forEach((session) => {
|
||||
othersMenu.append(createSessionItem(session, false));
|
||||
});
|
||||
const othersList = document.createElement('div');
|
||||
othersList.className = 'nav-list';
|
||||
others.forEach((session) => othersList.append(createSessionItem(session, false)));
|
||||
sessionsBlock.append(othersList);
|
||||
}
|
||||
|
||||
sessionsBlock.append(currentMenu, othersMenu);
|
||||
};
|
||||
|
||||
actions.querySelector('#reload-sessions-btn').addEventListener('click', async () => {
|
||||
async function reloadSessions() {
|
||||
try {
|
||||
await refreshSessions();
|
||||
buildList();
|
||||
setAuthInfo('Список сессий обновлён.');
|
||||
setAuthInfo('Список сеансов обновлён.');
|
||||
showToast('Список сеансов обновлён');
|
||||
} catch (error) {
|
||||
if (isSessionInvalidError(error)) {
|
||||
await terminateCurrentSession({
|
||||
@@ -165,9 +170,9 @@ export function render({navigate, chrome}) {
|
||||
return;
|
||||
}
|
||||
setAuthError(error.message);
|
||||
window.alert(error.message);
|
||||
showToast(error.message, { kind: 'error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
buildList();
|
||||
screen.append(actions, sessionsBlock);
|
||||
|
||||
@@ -58,7 +58,7 @@ export function render() {
|
||||
amountHint.style.fontSize = '22px';
|
||||
amountHint.style.fontWeight = '800';
|
||||
amountHint.style.lineHeight = '1.25';
|
||||
amountHint.style.color = '#1fc97a';
|
||||
amountHint.style.color = 'var(--success)';
|
||||
amountHint.style.textAlign = 'center';
|
||||
amountHint.textContent = '';
|
||||
|
||||
@@ -69,7 +69,7 @@ export function render() {
|
||||
balanceHint.style.margin = '0';
|
||||
balanceHint.style.textAlign = 'center';
|
||||
balanceHint.style.fontSize = '14px';
|
||||
balanceHint.style.color = 'rgba(255,255,255,0.9)';
|
||||
balanceHint.style.color = 'var(--text-primary)';
|
||||
balanceHint.textContent = '';
|
||||
|
||||
const closeHint = document.createElement('p');
|
||||
@@ -79,7 +79,7 @@ export function render() {
|
||||
closeHint.style.fontSize = '26px';
|
||||
closeHint.style.fontWeight = '800';
|
||||
closeHint.style.lineHeight = '1.25';
|
||||
closeHint.style.color = '#1fc97a';
|
||||
closeHint.style.color = 'var(--success)';
|
||||
closeHint.style.textAlign = 'center';
|
||||
closeHint.textContent = 'Можете закрыть эту страницу и продолжить регистрацию.';
|
||||
|
||||
@@ -140,7 +140,7 @@ export function render() {
|
||||
status.textContent = `Транзакция прошла.\nSignature: ${tx.signature}`;
|
||||
status.style.fontSize = '13px';
|
||||
status.style.fontWeight = '500';
|
||||
status.style.color = 'rgba(255,255,255,0.88)';
|
||||
status.style.color = 'var(--text-secondary)';
|
||||
fillBtn.style.display = 'none';
|
||||
actions.style.display = 'none';
|
||||
amountHint.style.display = '';
|
||||
|
||||
@@ -11,7 +11,7 @@ export const pageMeta = { id: 'entry-settings-view', title: 'Настройки
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
|
||||
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
@@ -50,7 +50,7 @@ export function render({ navigate }) {
|
||||
controls.className = 'row wrap-row';
|
||||
|
||||
const checkButton = document.createElement('button');
|
||||
checkButton.className = 'shine-btn shine-btn--settings server-check-btn';
|
||||
checkButton.className = 'secondary-btn server-check-btn';
|
||||
checkButton.type = 'button';
|
||||
checkButton.textContent = 'Проверить';
|
||||
|
||||
@@ -132,7 +132,7 @@ export function render({ navigate }) {
|
||||
|
||||
if (isLocalDemoAvailable()) {
|
||||
const localDemoButton = document.createElement('button');
|
||||
localDemoButton.className = 'shine-btn shine-btn--settings preauth-local-demo-btn';
|
||||
localDemoButton.className = 'secondary-btn preauth-local-demo-btn';
|
||||
localDemoButton.type = 'button';
|
||||
localDemoButton.textContent = 'Открыть локальный тестовый режим';
|
||||
localDemoButton.addEventListener('click', () => {
|
||||
@@ -143,7 +143,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const serverUiButton = document.createElement('button');
|
||||
serverUiButton.className = 'shine-btn shine-btn--settings';
|
||||
serverUiButton.className = 'secondary-btn';
|
||||
serverUiButton.type = 'button';
|
||||
serverUiButton.textContent = 'Настроить свой сервер';
|
||||
serverUiButton.addEventListener('click', () => {
|
||||
@@ -152,13 +152,13 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'shine-btn shine-btn--settings';
|
||||
cancelButton.className = 'secondary-btn';
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.textContent = 'Отмена';
|
||||
cancelButton.addEventListener('click', () => navigate('start-view'));
|
||||
|
||||
const saveButton = document.createElement('button');
|
||||
saveButton.className = 'shine-btn shine-btn--primary';
|
||||
saveButton.className = 'primary-btn';
|
||||
saveButton.type = 'button';
|
||||
saveButton.textContent = 'Сохранить';
|
||||
saveButton.addEventListener('click', async () => {
|
||||
|
||||
@@ -40,7 +40,7 @@ export function render({ navigate }) {
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
<label class="checkbox-row"><span class="field-label">Blockchain Key</span></label>
|
||||
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне SHiNE.</p>
|
||||
<p class="meta-muted key-storage-option__description">Используется для подписи ваших действий и записей в блокчейне Сияния.</p>
|
||||
<input class="input" type="text" value="${state.keyStorage.blockchainKey}" />
|
||||
</div>
|
||||
<div class="key-card stack">
|
||||
|
||||
@@ -14,55 +14,38 @@ export function render({navigate, chrome}) {
|
||||
const returnPage = resolveReturnPage();
|
||||
let pendingLanguage = state.entrySettings.language === 'en' ? 'en' : 'ru';
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
const topbar = createTopBar({
|
||||
title: 'Язык / Language',
|
||||
back: { label: '←', onClick: () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
} },
|
||||
}));
|
||||
});
|
||||
const embedTopbar = !state.session.isAuthorized;
|
||||
if (!embedTopbar) chrome?.setTopbar(topbar);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack language-choice-card';
|
||||
card.innerHTML = `
|
||||
<p class="meta-muted language-choice-hint">Выберите язык интерфейса</p>
|
||||
<div class="language-segmented-control" role="radiogroup" aria-label="Язык интерфейса">
|
||||
<button class="language-segment${pendingLanguage === 'ru' ? ' is-selected' : ''}" type="button" data-language="ru" role="radio" aria-checked="${pendingLanguage === 'ru'}">Русский</button>
|
||||
<button class="language-segment${pendingLanguage === 'en' ? ' is-selected' : ''}" type="button" data-language="en" role="radio" aria-checked="${pendingLanguage === 'en'}">English</button>
|
||||
<span class="language-segmented-thumb" aria-hidden="true"></span>
|
||||
</div>
|
||||
<div class="language-choice-actions">
|
||||
<button class="shine-btn shine-btn--settings" type="button" data-action="cancel">Отмена</button>
|
||||
<button class="shine-btn shine-btn--primary" type="button" data-action="ok">OK</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const syncSelection = () => {
|
||||
card.dataset.language = pendingLanguage;
|
||||
card.querySelectorAll('[data-language]').forEach((item) => {
|
||||
const selected = item.dataset.language === pendingLanguage;
|
||||
item.classList.toggle('is-selected', selected);
|
||||
item.setAttribute('aria-checked', String(selected));
|
||||
card.className = 'nav-list language-list';
|
||||
card.setAttribute('role', 'radiogroup');
|
||||
card.setAttribute('aria-label', 'Язык интерфейса / Interface language');
|
||||
[['ru', 'Русский'], ['en', 'English']].forEach(([code, label]) => {
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'nav-row language-row';
|
||||
row.setAttribute('role', 'radio');
|
||||
row.setAttribute('aria-checked', String(pendingLanguage === code));
|
||||
row.innerHTML = `<span class="nav-row__label"></span>`;
|
||||
row.querySelector('.nav-row__label').textContent = label;
|
||||
row.addEventListener('click', () => {
|
||||
pendingLanguage = code;
|
||||
saveEntryLanguage(code);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
};
|
||||
syncSelection();
|
||||
|
||||
card.querySelectorAll('[data-language]').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
pendingLanguage = button.dataset.language === 'en' ? 'en' : 'ru';
|
||||
syncSelection();
|
||||
});
|
||||
});
|
||||
card.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
});
|
||||
card.querySelector('[data-action="ok"]')?.addEventListener('click', () => {
|
||||
saveEntryLanguage(pendingLanguage);
|
||||
sessionStorage.removeItem('shine-language-return-page');
|
||||
navigate(returnPage);
|
||||
card.append(row);
|
||||
});
|
||||
|
||||
if (embedTopbar) screen.append(topbar);
|
||||
screen.append(card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export function render({ navigate }) {
|
||||
const remoteText = document.createElement('p');
|
||||
remoteText.className = 'auth-copy';
|
||||
const serverLoginEl = document.createElement('strong');
|
||||
const textBefore = document.createTextNode('Этот пользователь SHiNE зарегистрирован на другом сервере доступа: ');
|
||||
const textBefore = document.createTextNode('Этот пользователь Сияния зарегистрирован на другом сервере доступа: ');
|
||||
remoteText.append(textBefore, serverLoginEl, document.createTextNode('. Для входа перейдите на его сервер.'));
|
||||
|
||||
const serverLinkEl = document.createElement('a');
|
||||
@@ -111,11 +111,11 @@ export function render({ navigate }) {
|
||||
const resolution = String(resolved?.resolution || '').trim().toUpperCase();
|
||||
|
||||
if (resolution === 'NOT_FOUND') {
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в SHiNE.');
|
||||
setStatus(status, 'Пользователь с таким логином не зарегистрирован в Сиянии.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'NO_ACCESS_SERVER') {
|
||||
setStatus(status, 'Пользователь зарегистрирован в SHiNE, но для него не найден действующий сервер доступа.');
|
||||
setStatus(status, 'Пользователь зарегистрирован в Сиянии, но для него не найден действующий сервер доступа.');
|
||||
return;
|
||||
}
|
||||
if (resolution === 'REMOTE') {
|
||||
@@ -155,7 +155,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
createTopBar({
|
||||
title: '',
|
||||
title: 'Вход',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: async () => {
|
||||
|
||||
@@ -8,21 +8,13 @@ import {
|
||||
terminateCurrentSession,
|
||||
} 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 { 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';
|
||||
import { formatRelativeTime } from '../services/channels-ux.js';
|
||||
import { formatListTime } from '../services/channels-ux.js';
|
||||
|
||||
export const pageMeta = { id: 'messages-list', title: 'Личные сообщения' };
|
||||
const PREVIEW_MAX_LEN = 200;
|
||||
const SVG_CHEVRON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 6l6 6-6 6"></path>
|
||||
</svg>
|
||||
`;
|
||||
const DM_BLOB_PREVIEW_CACHE = new Map();
|
||||
const DM_BLOB_PREVIEW_PENDING = new Map();
|
||||
const dmAvatarSnapshotCache = new Map();
|
||||
@@ -119,6 +111,8 @@ function clipPreviewText(text, maxLen = PREVIEW_MAX_LEN) {
|
||||
return `${normalized.slice(0, maxLen - 1)}…`;
|
||||
}
|
||||
|
||||
const DM_PREVIEW_ENCRYPTED = 'Зашифрованное сообщение';
|
||||
|
||||
async function resolveDialogPreview(dialog) {
|
||||
const localText = clipPreviewText(String(dialog?.lastMessageText || '').trim());
|
||||
if (localText) return localText;
|
||||
@@ -131,6 +125,9 @@ async function resolveDialogPreview(dialog) {
|
||||
String(state.session.storagePwdInMemory || '').trim(),
|
||||
].join('|');
|
||||
|
||||
// После перезагрузки пароля хранилища в памяти нет — расшифровать нечем. Не кэшируем,
|
||||
// чтобы превью появилось, когда пароль снова будет введён.
|
||||
if (!String(state.session.storagePwdInMemory || '').trim()) return DM_PREVIEW_ENCRYPTED;
|
||||
if (DM_BLOB_PREVIEW_CACHE.has(cacheKey)) return DM_BLOB_PREVIEW_CACHE.get(cacheKey);
|
||||
if (DM_BLOB_PREVIEW_PENDING.has(cacheKey)) return DM_BLOB_PREVIEW_PENDING.get(cacheKey);
|
||||
|
||||
@@ -149,7 +146,7 @@ async function resolveDialogPreview(dialog) {
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, result);
|
||||
return result;
|
||||
} catch {
|
||||
const fallback = 'Сообщение недоступно';
|
||||
const fallback = DM_PREVIEW_ENCRYPTED;
|
||||
DM_BLOB_PREVIEW_CACHE.set(cacheKey, fallback);
|
||||
return fallback;
|
||||
} finally {
|
||||
@@ -185,7 +182,7 @@ function latestLocalDialogMessage(peerLogin) {
|
||||
}
|
||||
|
||||
function formatChatRowTime(ts) {
|
||||
return formatRelativeTime(ts);
|
||||
return formatListTime(ts);
|
||||
}
|
||||
|
||||
function compareChatRows(a, b) {
|
||||
@@ -201,63 +198,45 @@ 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 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 = document.createElement('button');
|
||||
filterTitle.type = 'button';
|
||||
filterTitle.className = 'dm-head-filter-title';
|
||||
filterTitle.textContent = 'Чаты';
|
||||
const filterLabels = {
|
||||
all: 'Чаты',
|
||||
close_friend: 'Близкие друзья',
|
||||
friend: 'Друзья',
|
||||
contact: 'Контакты',
|
||||
none: 'Новые',
|
||||
};
|
||||
const CHAT_FILTERS = [
|
||||
['all', 'Все'],
|
||||
['close_friend', 'Близкие'],
|
||||
['friend', 'Друзья'],
|
||||
['contact', 'Контакты'],
|
||||
['none', 'Новые'],
|
||||
];
|
||||
let reloadForFilter = () => {};
|
||||
const chatFilterMenu = createDropdownMenu({
|
||||
transparent: true,
|
||||
anchorEl: filterTitle,
|
||||
placement: 'bottom-start',
|
||||
leftShift: 72,
|
||||
minWidth: 225,
|
||||
items: [
|
||||
{ label: 'Все чаты', action: () => { currentChatFilter = 'all'; filterTitle.textContent = filterLabels.all; reloadForFilter(); } },
|
||||
{ label: 'Близкие друзья', action: () => { currentChatFilter = 'close_friend'; filterTitle.textContent = filterLabels.close_friend; reloadForFilter(); } },
|
||||
{ label: 'Друзья', action: () => { currentChatFilter = 'friend'; filterTitle.textContent = filterLabels.friend; reloadForFilter(); } },
|
||||
{ label: 'Контакты', action: () => { currentChatFilter = 'contact'; filterTitle.textContent = filterLabels.contact; reloadForFilter(); } },
|
||||
{ label: 'Новые', action: () => { currentChatFilter = 'none'; filterTitle.textContent = filterLabels.none; reloadForFilter(); } },
|
||||
],
|
||||
const filterRow = document.createElement('div');
|
||||
filterRow.className = 'chip-row chip-row--bleed dm-filter-row';
|
||||
filterRow.setAttribute('role', 'toolbar');
|
||||
filterRow.setAttribute('aria-label', 'Фильтр чатов');
|
||||
CHAT_FILTERS.forEach(([value, label]) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.type = 'button';
|
||||
chip.className = 'chip';
|
||||
chip.dataset.filter = value;
|
||||
chip.textContent = label;
|
||||
chip.setAttribute('aria-pressed', String(value === currentChatFilter));
|
||||
chip.addEventListener('click', () => {
|
||||
if (currentChatFilter === value) return;
|
||||
currentChatFilter = value;
|
||||
filterRow.querySelectorAll('.chip').forEach((el) => el.setAttribute('aria-pressed', String(el.dataset.filter === value)));
|
||||
reloadForFilter();
|
||||
});
|
||||
filterRow.append(chip);
|
||||
});
|
||||
|
||||
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,
|
||||
title: 'Чаты',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню чатов',
|
||||
ariaLabel: 'Меню чатов',
|
||||
className: 'messages-topbar-menu-btn topbar-overflow-action--raised',
|
||||
menu: {
|
||||
minWidth: 210,
|
||||
items: [
|
||||
{ label: 'Поиск пользователей', iconHtml: searchIconHtml, action: () => navigate('contact-search-view') },
|
||||
],
|
||||
},
|
||||
icon: 'search',
|
||||
title: 'Найти человека',
|
||||
ariaLabel: 'Найти человека',
|
||||
className: 'messages-search-btn',
|
||||
onClick: () => navigate('contact-search-view'),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -269,9 +248,7 @@ function renderRow(item) {
|
||||
const row = document.createElement('article');
|
||||
row.className = 'list-item dm-dialog-card';
|
||||
const relationFlag = normalizeRelationFlag(item.relationFlag);
|
||||
const relationBadge = relationFlag === 'none'
|
||||
? 'не в контактах'
|
||||
: relationLabel(relationFlag);
|
||||
const relationBadge = relationFlag === 'none' ? '' : relationLabel(relationFlag);
|
||||
const avatarEl = createDmAvatar(item.peerLogin, {
|
||||
avatar: item.avatar,
|
||||
firstName: item.firstName,
|
||||
@@ -280,19 +257,19 @@ function renderRow(item) {
|
||||
avatarEl.classList.add('avatar');
|
||||
const avatarWrap = document.createElement('div');
|
||||
avatarWrap.className = 'dm-av dm-av--default';
|
||||
if (relationFlag !== 'none') row.classList.add(`dm-card--${relationFlag === 'close_friend' ? 'family' : 'contact'}`);
|
||||
avatarWrap.append(avatarEl);
|
||||
row.innerHTML = `
|
||||
<div class="dm-row-main">
|
||||
<div class="dm-row-titleline dm-row-titlewrap">
|
||||
<strong class="dm-row-title"></strong>
|
||||
<span class="dm-contact-note">${relationBadge}</span>
|
||||
${relationBadge ? `<span class="dm-contact-note dm-contact-note--${relationFlag}">${relationBadge}</span>` : ''}
|
||||
</div>
|
||||
<p class="dm-row-last-message"></p>
|
||||
</div>
|
||||
<div class="dm-row-meta-col">
|
||||
<div class="dm-row-meta-line">
|
||||
${item.lastMessageTimeMs ? '<span class="dm-row-time"></span>' : '<span class="dm-row-time dm-row-time--empty"></span>'}
|
||||
<span class="dm-chevron">${SVG_CHEVRON}</span>
|
||||
</div>
|
||||
${item.unreadCount ? `<span class="dm-unread-badge">${item.unreadCount > 99 ? '99+' : item.unreadCount}</span>` : '<span class="dm-row-meta-spacer" aria-hidden="true"></span>'}
|
||||
</div>
|
||||
@@ -310,6 +287,7 @@ function renderRow(item) {
|
||||
void resolveDialogPreview(item).then((text) => {
|
||||
if (!previewEl?.isConnected) return;
|
||||
previewEl.textContent = String(text || '').trim() || 'Диалог пока пуст.';
|
||||
previewEl.classList.toggle('is-encrypted', text === DM_PREVIEW_ENCRYPTED);
|
||||
});
|
||||
row.addEventListener('click', () => navigate(`chat/${encodeURIComponent(normalizeDmChatId(item.peerLogin))}`));
|
||||
return row;
|
||||
@@ -387,20 +365,13 @@ function renderRow(item) {
|
||||
|
||||
if (!rows.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'card meta-muted';
|
||||
empty.textContent = 'Пока нет диалогов';
|
||||
empty.className = 'empty-note';
|
||||
empty.textContent = 'Пока нет переписок. Найдите человека через поиск вверху.';
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
let dividerInserted = false;
|
||||
rows.forEach((item) => {
|
||||
if (!dividerInserted && normalizeRelationFlag(item.relationFlag) === 'none' && list.childNodes.length > 0) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'dm-divider';
|
||||
list.append(divider);
|
||||
dividerInserted = true;
|
||||
}
|
||||
list.append(renderRow(item));
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -444,12 +415,10 @@ function renderRow(item) {
|
||||
reloadForFilter = () => { void loadList(); };
|
||||
|
||||
chrome?.setTopbar(head);
|
||||
screen.append(list);
|
||||
screen.append(filterRow, list);
|
||||
loadList();
|
||||
|
||||
screen.cleanup = () => {
|
||||
chatFilterMenu.destroy();
|
||||
};
|
||||
screen.cleanup = () => {};
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
@@ -358,7 +357,7 @@ async function buildSecondLevelEngineModel(baseModel, getGraph) {
|
||||
.filter(Boolean);
|
||||
if (failedParents.length) {
|
||||
const sample = failedParents.slice(0, 3).map((node) => node.login || node.id).join(', ');
|
||||
throw new Error(`X2: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
|
||||
throw new Error(`Друзья друзей: не удалось загрузить связи ${failedParents.length} из ${directFriends.length} друзей${sample ? ` (${sample})` : ''}`);
|
||||
}
|
||||
|
||||
// ФАЗА 3. Из уже полностью полученных графов собираем кандидатов второго уровня и все рёбра.
|
||||
@@ -930,16 +929,16 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
|
||||
function updateHistoryChip() {
|
||||
if (!(historyChip instanceof HTMLButtonElement)) return;
|
||||
historyChip.textContent = historyDepth > 0 ? `История ${historyDepth}` : 'История';
|
||||
historyChip.textContent = historyDepth > 0 ? `Недавние · ${historyDepth}` : 'Недавние';
|
||||
historyChip.classList.toggle('is-active', historyDepth > 0);
|
||||
historyChip.setAttribute('aria-pressed', historyDepth > 0 ? 'true' : 'false');
|
||||
historyChip.disabled = x2Enabled;
|
||||
historyChip.setAttribute('aria-disabled', x2Enabled ? 'true' : 'false');
|
||||
historyChip.title = x2Enabled
|
||||
? 'X2 показывает отдельную карту и временно не использует историю.'
|
||||
? '«Друзья друзей» показывают отдельную карту — недавние временно не используются.'
|
||||
: (historyDepth > 0
|
||||
? `Хранить предыдущих центров: ${historyDepth}. Нажмите для следующего значения.`
|
||||
: 'История выключена. Нажмите, чтобы хранить 1 предыдущий центр.');
|
||||
? `На карте остаются последние ${historyDepth} человека, через которых вы переходили. Нажмите, чтобы изменить.`
|
||||
: 'Недавние выключены. Нажмите, чтобы на карте оставался предыдущий человек.');
|
||||
}
|
||||
|
||||
function cycleHistoryDepth() {
|
||||
@@ -956,7 +955,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
if (!(x2Chip instanceof HTMLButtonElement)) return;
|
||||
x2Chip.classList.toggle('is-active', x2Enabled);
|
||||
x2Chip.setAttribute('aria-pressed', x2Enabled ? 'true' : 'false');
|
||||
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы выключить X2.' : 'Показать друзей друзей.';
|
||||
x2Chip.title = x2Enabled ? 'Показаны друзья друзей. Нажмите, чтобы скрыть.' : 'Показать друзей друзей.';
|
||||
updateHistoryChip();
|
||||
}
|
||||
|
||||
@@ -1289,26 +1288,16 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
}
|
||||
|
||||
|
||||
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 header = createTopBar({
|
||||
title: 'Связи',
|
||||
className: 'topbar--root',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню связей',
|
||||
ariaLabel: 'Открыть меню связей',
|
||||
className: 'chat-header-icon-btn network-header-menu-btn',
|
||||
menu: {
|
||||
minWidth: 220,
|
||||
items: [
|
||||
{ label: 'Найти пользователя', iconHtml: searchIconHtml, action: openSearchModal },
|
||||
],
|
||||
},
|
||||
icon: 'search',
|
||||
title: 'Найти пользователя',
|
||||
ariaLabel: 'Найти пользователя',
|
||||
className: 'network-search-btn',
|
||||
onClick: openSearchModal,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1365,7 +1354,7 @@ export function render({ navigate, route, chrome } = {}) {
|
||||
x2Chip = document.createElement('button');
|
||||
x2Chip.type = 'button';
|
||||
x2Chip.className = 'fg-filter-chip fg-x2-chip';
|
||||
x2Chip.textContent = 'X2';
|
||||
x2Chip.textContent = 'Друзья друзей';
|
||||
x2Chip.addEventListener('click', () => { void toggleX2(); });
|
||||
filterBar.append(x2Chip);
|
||||
updateX2Chip();
|
||||
|
||||
@@ -106,8 +106,27 @@ function easeOutCubic(t) {
|
||||
return 1 - x * x * x;
|
||||
}
|
||||
|
||||
const RELATION_COLORS_LIGHT = {
|
||||
family: 'rgba(184, 92, 20, 0.9)',
|
||||
friend: 'rgba(38, 96, 196, 0.9)',
|
||||
close_friend: 'rgba(38, 96, 196, 0.98)',
|
||||
business: 'rgba(108, 62, 200, 0.9)',
|
||||
contact: 'rgba(92, 98, 121, 0.75)',
|
||||
};
|
||||
|
||||
function isLightTheme() {
|
||||
return document.documentElement.dataset.theme === 'light';
|
||||
}
|
||||
|
||||
function shineStroke() {
|
||||
return isLightTheme()
|
||||
? { halo: '#0a7fb3', core: '#06567a', blend: 'normal' }
|
||||
: { halo: '#00e5ff', core: '#dffaff', blend: 'screen' };
|
||||
}
|
||||
|
||||
function relationColor(relationType) {
|
||||
return RELATION_COLORS[relationType] || RELATION_COLORS.contact;
|
||||
const palette = isLightTheme() ? RELATION_COLORS_LIGHT : RELATION_COLORS;
|
||||
return palette[relationType] || palette.contact;
|
||||
}
|
||||
|
||||
function resolveAvatarPhotoSrc(src) {
|
||||
@@ -1057,8 +1076,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// 3-й уровень: тонкая нить В ЦВЕТЕ СВЯЗИ (видна при раскрытии). Сияющая — светится (ореол+ядро).
|
||||
if (pe > 0.02) {
|
||||
if (shine) {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
|
||||
const ss = shineStroke();
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="2.6" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.1" stroke-linecap="round" opacity="${(0.85 * pe * sp).toFixed(2)}" />`);
|
||||
} else {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="0.8" stroke-linecap="round" opacity="${(0.34 * pe * sp).toFixed(2)}" />`);
|
||||
}
|
||||
@@ -1067,8 +1087,9 @@ export function createForceGraph({ stage, model, onCenterTap, onNodeTap, onNodeL
|
||||
// 2-й уровень: связь В ЦВЕТЕ ТИПА (семья/друзья/...). Сияющая связь — светящаяся линия.
|
||||
if (pe > 0.02) {
|
||||
if (shine) {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#00e5ff" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:screen" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="#dffaff" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
|
||||
const ss = shineStroke();
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.halo}" stroke-width="3.2" stroke-linecap="round" opacity="${(0.46 * pe * sp).toFixed(2)}" filter="url(#fg-plasma-blur2)" style="mix-blend-mode:${ss.blend}" />`);
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${ss.core}" stroke-width="1.3" stroke-linecap="round" opacity="${(0.9 * pe * sp).toFixed(2)}" />`);
|
||||
} else {
|
||||
parts.push(`<path d="${d}" fill="none" stroke="${relationColor(n.relationType)}" stroke-width="1.0" stroke-linecap="round" opacity="${(0.42 * pe * sp).toFixed(2)}" />`);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ function renderEmpty(activeTab) {
|
||||
title.textContent = activeTab === 'events' ? 'Пока нет событий' : activeTab === 'connections' ? 'Пока нет связей' : 'Пока нет ответов';
|
||||
const text = document.createElement('p');
|
||||
text.className = 'meta-muted';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или треде, ответ появится здесь.';
|
||||
text.textContent = activeTab === 'events' ? 'Подписки и отписки от ваших каналов появятся здесь.' : activeTab === 'connections' ? 'Изменения дружбы и подтверждений появятся здесь.' : 'Когда кто-то ответит на ваше сообщение в канале или обсуждении, ответ появится здесь.';
|
||||
card.append(title, text);
|
||||
return card;
|
||||
}
|
||||
@@ -322,12 +322,15 @@ export function render({ navigate, chrome } = {}) {
|
||||
screen.className = 'stack notifications-screen';
|
||||
|
||||
const tabs = document.createElement('div');
|
||||
tabs.className = 'notification-feed-tabs app-top-tabs';
|
||||
tabs.className = 'tabs tabs--three notification-feed-tabs';
|
||||
tabs.setAttribute('role', 'radiogroup');
|
||||
tabs.setAttribute('aria-label', 'Категория уведомлений');
|
||||
const tabDefs = [['replies','Ответы'],['connections','Связи'],['events','События']];
|
||||
chrome?.setTopbar(createTopBar({
|
||||
center: tabs,
|
||||
className: 'notifications-topbar',
|
||||
title: 'Уведомления',
|
||||
className: 'topbar--root notifications-topbar',
|
||||
}));
|
||||
screen.append(tabs);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'stack notifications-list';
|
||||
@@ -352,15 +355,22 @@ export function render({ navigate, chrome } = {}) {
|
||||
if (!btn) return;
|
||||
let badge = btn.querySelector('.notification-toolbar-badge');
|
||||
if (state.notificationUnreadTotal <= 0) { badge?.remove(); return; }
|
||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; btn.append(badge); }
|
||||
if (!badge) { badge = document.createElement('span'); badge.className = 'toolbar-unread-badge notification-toolbar-badge'; (btn.querySelector('.toolbar-icon') || btn).append(badge); }
|
||||
badge.textContent = state.notificationUnreadTotal > 99 ? '99+' : String(state.notificationUnreadTotal);
|
||||
}
|
||||
|
||||
function renderTabs(payload) {
|
||||
const counts = countsFromPayload(payload);
|
||||
tabs.replaceChildren(...tabDefs.map(([key,label]) => {
|
||||
const b=document.createElement('button'); b.type='button'; b.className=`fg-filter-chip notification-tab-btn ${state.notificationsTab===key?'is-active':''}`; b.dataset.tab=key; b.setAttribute('aria-selected',state.notificationsTab===key?'true':'false');
|
||||
b.textContent = counts[key] > 0 ? `${label} ${counts[key]}` : label;
|
||||
const b=document.createElement('button'); b.type='button'; b.className='tab-btn notification-tab-btn'; b.dataset.tab=key; b.setAttribute('role','radio'); b.setAttribute('aria-checked',state.notificationsTab===key?'true':'false');
|
||||
b.textContent = label;
|
||||
if (counts[key] > 0) {
|
||||
const count = document.createElement('span');
|
||||
count.className = 'tab-count';
|
||||
count.textContent = counts[key] > 99 ? '99+' : String(counts[key]);
|
||||
count.setAttribute('aria-label', `новых: ${counts[key]}`);
|
||||
b.append(count);
|
||||
}
|
||||
b.addEventListener('click',()=>{ if(state.notificationsTab===key)return; state.notificationsTab=key; renderCurrent(); });
|
||||
return b;
|
||||
}));
|
||||
|
||||
@@ -114,7 +114,7 @@ export function render({ navigate, chrome }) {
|
||||
);
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'stack profile-edit-card';
|
||||
|
||||
const topRow = document.createElement('div');
|
||||
topRow.className = 'row';
|
||||
@@ -127,14 +127,14 @@ export function render({ navigate, chrome }) {
|
||||
<div class="profile-identity-line profile-identity-login">${String(login || '').trim() || 'unknown'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary-btn" type="button" data-reload="true">Обновить</button>
|
||||
<button class="secondary-btn profile-edit-reload" type="button" data-reload="true" hidden>Обновить</button>
|
||||
`;
|
||||
|
||||
const badgesRow = document.createElement('div');
|
||||
badgesRow.className = 'row';
|
||||
badgesRow.className = 'chip-row profile-edit-statuses';
|
||||
badgesRow.innerHTML = `
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="account_role">Аккаунт: Не указано</button>
|
||||
<button class="badge profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
|
||||
<button class="chip profile-toggle-btn is-no" type="button" data-status="account_role">Тип аккаунта: Не указано</button>
|
||||
<button class="chip profile-toggle-btn is-no" type="button" data-status="shine">Сияние: Не указано</button>
|
||||
`;
|
||||
|
||||
const status = document.createElement('div');
|
||||
@@ -142,16 +142,13 @@ export function render({ navigate, chrome }) {
|
||||
status.textContent = 'Загрузка параметров...';
|
||||
|
||||
const listWrap = document.createElement('div');
|
||||
listWrap.className = 'stack profile-param-list';
|
||||
listWrap.className = 'nav-list profile-param-list';
|
||||
|
||||
const relativesCard = document.createElement('div');
|
||||
relativesCard.className = 'card stack';
|
||||
relativesCard.innerHTML = `
|
||||
<div class="profile-param-value"><b>Друзья</b></div>
|
||||
<div class="meta-muted">
|
||||
Добавьте пользователя в друзья или в близкие друзья.
|
||||
Родственные типы связей сохранены в протоколе, но пока скрыты из интерфейса.
|
||||
</div>
|
||||
<div class="meta-muted">Добавьте человека в друзья или в близкие друзья.</div>
|
||||
<button class="secondary-btn" type="button" data-add-relative="true">Добавить друга</button>
|
||||
`;
|
||||
|
||||
@@ -250,7 +247,7 @@ export function render({ navigate, chrome }) {
|
||||
|
||||
function updateStatusesUi() {
|
||||
if (accountRoleBtn) {
|
||||
accountRoleBtn.textContent = `Аккаунт: ${accountRoleLabel(currentAccountRole)}`;
|
||||
accountRoleBtn.textContent = `Тип аккаунта: ${accountRoleLabel(currentAccountRole)}`;
|
||||
accountRoleBtn.classList.remove('is-no', 'is-yes-official', 'is-yes-shine', 'is-not-interested');
|
||||
accountRoleBtn.classList.add(currentAccountRole === PROFILE_ACCOUNT_ROLE_PRIMARY ? 'is-yes-official' : 'is-no');
|
||||
}
|
||||
@@ -582,22 +579,22 @@ export function render({ navigate, chrome }) {
|
||||
function renderFields(fields) {
|
||||
listWrap.innerHTML = '';
|
||||
fields.forEach((field) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'card profile-param-item row';
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'nav-row profile-param-item';
|
||||
row.dataset.editField = field.key;
|
||||
row.style.cursor = 'pointer';
|
||||
const value = String(field.value || '').trim() || 'не заполнено';
|
||||
const isNameField = field.key === 'first_name' || field.key === 'last_name';
|
||||
const valueClass = isNameField ? 'profile-param-value profile-param-value-small' : 'profile-param-value';
|
||||
row.innerHTML = `<div class="${valueClass}"><b>${field.label}</b>: ${escapeHtml(value)}</div>`;
|
||||
const value = String(field.value || '').trim();
|
||||
row.innerHTML = `<span class="nav-row__hint">${escapeHtml(field.label)}</span><span class="nav-row__label${value ? '' : ' is-empty'}">${escapeHtml(value || 'Не заполнено')}</span>`;
|
||||
listWrap.append(row);
|
||||
|
||||
if (field.key === 'last_name') {
|
||||
const genderRow = document.createElement('div');
|
||||
genderRow.className = 'card profile-param-item row';
|
||||
const genderRow = document.createElement('button');
|
||||
genderRow.type = 'button';
|
||||
genderRow.className = 'nav-row profile-param-item';
|
||||
genderRow.dataset.editGender = 'true';
|
||||
genderRow.style.cursor = 'pointer';
|
||||
genderRow.innerHTML = `<div class="profile-param-value"><b>Пол</b>: <span data-gender-value>${escapeHtml(genderLabel(currentGender))}</span></div>`;
|
||||
const genderText = genderLabel(currentGender);
|
||||
const genderEmpty = genderText === 'Не указан';
|
||||
genderRow.innerHTML = `<span class="nav-row__hint">Пол</span><span class="nav-row__label${genderEmpty ? ' is-empty' : ''}" data-gender-value>${escapeHtml(genderEmpty ? 'Не указан' : genderText)}</span>`;
|
||||
listWrap.append(genderRow);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,104 +1,20 @@
|
||||
import { profile } from '../mock-data.js';
|
||||
import { state } from '../state.js';
|
||||
import { renderUserAvatar } from '../components/avatar-image.js';
|
||||
import { createOverflowDots } from '../components/overflow-dots.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
|
||||
|
||||
export const pageMeta = { id: 'profile-view', title: 'Профиль' };
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text || '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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 friendsMetricHtml(stats = {}) {
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric is-social-metric is-friends-combined"
|
||||
data-profile-list="friends"
|
||||
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
|
||||
>
|
||||
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
|
||||
<span class="user-profile-metric-label">Друзья</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function hasContacts(card) {
|
||||
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
|
||||
}
|
||||
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
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 user-profile-screen';
|
||||
|
||||
const topbar = createTopBar({
|
||||
title: login || 'Профиль',
|
||||
className: 'topbar--profile user-profile-header',
|
||||
actions: [
|
||||
{
|
||||
iconNode: createOverflowDots(),
|
||||
title: 'Меню профиля',
|
||||
ariaLabel: 'Меню профиля',
|
||||
className: 'profile-head-menu-btn topbar-overflow-action--raised',
|
||||
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') },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'Профиль',
|
||||
className: 'topbar--root user-profile-header',
|
||||
});
|
||||
chrome?.setTopbar(topbar);
|
||||
|
||||
@@ -114,57 +30,21 @@ export function render({ navigate, chrome }) {
|
||||
|
||||
function renderProfile() {
|
||||
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 displayName = fullName || card.login || login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = topbar.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login || login || 'Профиль';
|
||||
|
||||
body.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login || login)}</div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-social-metrics">
|
||||
${friendsMetricHtml(stats)}
|
||||
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
|
||||
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
|
||||
</div>
|
||||
|
||||
<section class="user-profile-about-card" aria-label="О себе">
|
||||
<div class="user-profile-about-title">О себе</div>
|
||||
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
|
||||
</section>
|
||||
|
||||
${(contactsVisible || spiritualPath) ? `
|
||||
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о профиле">
|
||||
${contactsVisible ? `<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>` : '<span></span>'}
|
||||
${spiritualPath ? `<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>` : '<span></span>'}
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="profile-view-detail-panel" aria-live="polite" hidden></section>` : ''}
|
||||
|
||||
<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>
|
||||
body.innerHTML = profileCardHtml({
|
||||
card,
|
||||
login,
|
||||
tilesHtml: [
|
||||
profileTileHtml({ icon: 'edit', label: 'Редактировать', attrs: 'data-self-profile-action="edit"' }),
|
||||
profileTileHtml({ icon: 'wallet', label: 'Кошелёк', attrs: 'data-self-profile-action="wallet"' }),
|
||||
profileTileHtml({ icon: 'settings', label: 'Настройки', attrs: 'data-self-profile-action="settings"' }),
|
||||
].join(''),
|
||||
}) + `
|
||||
<div class="nav-list profile-more-list">
|
||||
<button class="nav-row" type="button" data-profile-list="primary_given"><span class="nav-row__label">Подтверждённые мной аккаунты</span><span class="nav-row__hint">Аккаунты, за которые вы поручились</span></button>
|
||||
<button class="nav-row" type="button" data-profile-list="shine_given"><span class="nav-row__label">Отмеченные мной как сияющие</span></button>
|
||||
<button class="nav-row" type="button" data-self-profile-action="profiles"><span class="nav-row__label">Сменить профиль</span><span class="nav-row__hint">Другие аккаунты на этом устройстве</span></button>
|
||||
</div>`;
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
@@ -198,31 +78,10 @@ export function render({ navigate, chrome }) {
|
||||
if (action === 'edit') navigate('profile-edit-view');
|
||||
if (action === 'wallet') navigate('wallet-view');
|
||||
if (action === 'settings') navigate('settings-view');
|
||||
if (action === 'profiles') navigate('profiles-view');
|
||||
return;
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import {
|
||||
closeAllSavedProfiles,
|
||||
closeSavedProfile,
|
||||
getSavedProfiles,
|
||||
prepareAddProfileLogin,
|
||||
state,
|
||||
switchToSavedProfile,
|
||||
} from '../state.js';
|
||||
|
||||
@@ -58,12 +58,12 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const closeAllButton = document.createElement('button');
|
||||
closeAllButton.type = 'button';
|
||||
closeAllButton.className = 'secondary-btn profiles-close-all';
|
||||
closeAllButton.textContent = 'Закрыть все профили';
|
||||
closeAllButton.className = 'destructive-btn profiles-close-all';
|
||||
closeAllButton.textContent = 'Выйти из всех профилей';
|
||||
closeAllButton.addEventListener('click', async () => {
|
||||
const profiles = getSavedProfiles();
|
||||
if (!profiles.length) return;
|
||||
const confirmed = window.confirm('Закрыть все профили на этом устройстве? После этого откроется экран входа.');
|
||||
const confirmed = await confirmDialog({ title: 'Выйти из всех профилей?', text: 'Все профили на этом устройстве будут закрыты, откроется экран входа.', confirmLabel: 'Выйти', danger: true });
|
||||
if (!confirmed) return;
|
||||
closeAllButton.disabled = true;
|
||||
status.hidden = false;
|
||||
@@ -83,10 +83,8 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const renderList = () => {
|
||||
const profiles = getSavedProfiles();
|
||||
const active = profiles.find((item) => item.isActive);
|
||||
intro.textContent = profiles.length
|
||||
? `Профилей на устройстве: ${profiles.length}. Активен: ${active?.login || state.session.login || '—'}`
|
||||
: 'На устройстве нет сохранённых профилей.';
|
||||
intro.textContent = profiles.length ? '' : 'На устройстве нет сохранённых профилей.';
|
||||
intro.hidden = profiles.length > 0;
|
||||
closeAllButton.disabled = profiles.length === 0;
|
||||
list.innerHTML = '';
|
||||
|
||||
@@ -97,11 +95,11 @@ export function render({navigate, chrome}) {
|
||||
const select = document.createElement('button');
|
||||
select.type = 'button';
|
||||
select.className = 'profiles-select';
|
||||
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="badge">Активный</span>' : ''}`;
|
||||
select.innerHTML = `<span class="profiles-login">${profile.login}</span>${profile.isActive ? '<span class="profiles-active-badge">Сейчас открыт</span>' : ''}`;
|
||||
select.disabled = profile.isActive;
|
||||
select.addEventListener('click', async () => {
|
||||
if (profile.isActive) return;
|
||||
const confirmed = window.confirm(`Переключиться на профиль «${profile.login}»?`);
|
||||
const confirmed = await confirmDialog({ title: 'Переключить профиль?', text: `Откроется профиль «${profile.login}».`, confirmLabel: 'Переключить' });
|
||||
if (!confirmed) return;
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
@@ -118,16 +116,17 @@ export function render({navigate, chrome}) {
|
||||
const close = document.createElement('button');
|
||||
close.type = 'button';
|
||||
close.className = 'profiles-close';
|
||||
close.setAttribute('aria-label', `Закрыть профиль ${profile.login}`);
|
||||
close.setAttribute('aria-label', `Выйти из профиля ${profile.login}`);
|
||||
close.title = 'Выйти из профиля';
|
||||
close.textContent = '×';
|
||||
close.addEventListener('click', async () => {
|
||||
const others = profiles.filter((item) => item.login.toLowerCase() !== profile.login.toLowerCase());
|
||||
const message = profile.isActive
|
||||
? (others.length
|
||||
? `Закрыть текущий профиль «${profile.login}»? После закрытия приложение переключится на следующий сохранённый профиль.`
|
||||
: `Закрыть текущий профиль «${profile.login}»? После закрытия откроется экран входа.`)
|
||||
: `Закрыть профиль «${profile.login}» на этом устройстве?`;
|
||||
if (!window.confirm(message)) return;
|
||||
? 'Приложение переключится на следующий сохранённый профиль.'
|
||||
: 'После выхода откроется экран входа.')
|
||||
: 'Профиль будет закрыт на этом устройстве.';
|
||||
if (!await confirmDialog({ title: `Выйти из профиля «${profile.login}»?`, text: message, confirmLabel: 'Выйти', danger: true })) return;
|
||||
|
||||
status.hidden = false;
|
||||
status.className = 'status-line';
|
||||
|
||||
@@ -247,10 +247,10 @@ function queueSeedFor(queueId) {
|
||||
|
||||
function styleInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
field.style.webkitTextFillColor = '#111111';
|
||||
field.style.caretColor = '#111111';
|
||||
field.style.backgroundColor = '#ffffff';
|
||||
field.style.color = 'var(--text-primary)';
|
||||
field.style.webkitTextFillColor = 'var(--text-primary)';
|
||||
field.style.caretColor = 'var(--accent)';
|
||||
field.style.backgroundColor = 'var(--surface)';
|
||||
}
|
||||
|
||||
function readTicketFromUrl() {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../services/password-words.js';
|
||||
import { openRegistrationFaq } from './registration-faq-view.js';
|
||||
|
||||
export const pageMeta = { id: 'register-view', title: 'Зарегистрироваться', showAppChrome: false };
|
||||
export const pageMeta = { id: 'register-view', title: 'Регистрация', showAppChrome: false };
|
||||
const MIN_REGISTRATION_LOGIN_LENGTH = 8;
|
||||
|
||||
function normalizeLoginForTemporaryUiGuard(login) {
|
||||
@@ -132,7 +132,7 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
function updatePasswordLength() {
|
||||
passwordLengthText.textContent = `Итоговая длина пароля: ${getCurrentPassword().length} символов.`;
|
||||
passwordLengthText.textContent = `Символов в пароле: ${getCurrentPassword().length}`;
|
||||
}
|
||||
|
||||
function setStatusMessage(message, kind = '') {
|
||||
@@ -425,7 +425,7 @@ export function render({ navigate }) {
|
||||
|
||||
screen.append(
|
||||
createTopBar({
|
||||
title: 'Зарегистрироваться',
|
||||
title: 'Регистрация',
|
||||
back: {
|
||||
label: '←',
|
||||
onClick: () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
title: 'У кого хранятся ключи?',
|
||||
paragraphs: [
|
||||
'Ключи хранятся только у вас: на вашем устройстве, на доверенных устройствах или на отдельном внешнем устройстве, которое вы контролируете сами.',
|
||||
'SHiNE не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
|
||||
'Сияние не хранит ваши приватные ключи на сервере. Сервер помогает с доставкой и синхронизацией, но не владеет вашим секретом.',
|
||||
'Если захотите, ключи можно держать на отдельном полностью программируемом устройстве с открытым кодом, например на ESP32-контроллере.',
|
||||
],
|
||||
},
|
||||
@@ -26,11 +26,11 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
},
|
||||
{
|
||||
id: 'key-derivation',
|
||||
shortTitle: 'Деривация',
|
||||
shortTitle: 'Как пароль превращается в ключи',
|
||||
title: 'Как генерируются ключи и что делает пароль?',
|
||||
paragraphs: [
|
||||
'Из вашего логина и пароля с помощью Argon2id вычисляется специальный секрет.',
|
||||
'Уже из этого секрета детерминированно строятся четыре основных ключа: recovery key, root key, blockchain key и client key.',
|
||||
'Уже из этого секрета детерминированно строятся четыре основных ключа: ключ восстановления, главный ключ, ключ публикаций и ключ устройства.',
|
||||
'Это значит, что логин и пароль не просто проверяются на сервере, а реально участвуют в создании ваших ключей. У разных логинов даже с одинаковым паролем будут разные ключи.',
|
||||
],
|
||||
},
|
||||
@@ -39,9 +39,9 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
shortTitle: 'Три ключа',
|
||||
title: 'Зачем нужны три ключа?',
|
||||
paragraphs: [
|
||||
'Root key нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
|
||||
'Blockchain key нужен для подписания действий и записей в блокчейне SHiNE.',
|
||||
'Client key нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
|
||||
'Главный ключ нужен для управления вашей основной публичной записью и важными изменениями личности, включая обновление главной публичной части в Solana.',
|
||||
'Ключ публикаций нужен для подписания действий и записей в блокчейне Сияния.',
|
||||
'Ключ устройства нужен для входов и работы конкретного устройства. Благодаря разделению ключей можно точнее выдавать права одним устройствам и не выдавать другим.',
|
||||
'Если не хочется в это вникать, обычно можно просто сохранить все ключи на своём устройстве. Для большинства обычных сценариев на iPhone, Android и Linux это вполне практично. Для больших сумм или повышенного риска лучше отдельное внешнее устройство.',
|
||||
],
|
||||
},
|
||||
@@ -78,9 +78,9 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
{
|
||||
id: 'first-server',
|
||||
shortTitle: 'Первый сервер',
|
||||
title: 'Что такое первый сервер SHiNE?',
|
||||
title: 'Что такое первый сервер Сияния?',
|
||||
paragraphs: [
|
||||
'Первый сервер SHiNE это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
|
||||
'Первый сервер Сияния это тот сервер, на который вам будут писать и звонить в самом начале. При регистрации он записывается как ваш первый сервер доступа.',
|
||||
'Позже вы сможете сменить сервер, а ваши данные останутся с вами. В будущем серверов может быть несколько одновременно.',
|
||||
'Если серверов несколько, данные между ними будут синхронизироваться автоматически. Если добавляете новый сервер и убираете старый, просто дождитесь завершения синхронизации перед отключением старого.',
|
||||
'Если у вас не остаётся ни одного сервера, синхронизации, конечно, не будет, пока не появится хотя бы один активный сервер снова.',
|
||||
@@ -93,7 +93,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
paragraphs: [
|
||||
'На новом устройстве выберите вход через другое устройство и получите код подключения.',
|
||||
'На уже авторизованном устройстве откройте: Профиль → Настройки → Устройства → Подключить по коду. Введите или выберите соответствующий код и подтвердите подключение.',
|
||||
'Client key передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны root key и/или blockchain key, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
'Ключ устройства передаётся новому клиентскому устройству всегда. Если на доверенном устройстве доступны главный ключ и/или ключ публикаций, перед подтверждением можно отдельно выбрать, какие дополнительные ключи передать.',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -118,7 +118,7 @@ export const REGISTRATION_FAQ_TOPICS = [
|
||||
shortTitle: 'Кошелёк',
|
||||
title: 'Можно ли использовать такое устройство как кошелёк?',
|
||||
paragraphs: [
|
||||
'Да. Идея SHiNE в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
|
||||
'Да. Идея Сияния в том, что устройство может подписывать не только внутренние действия, но и любые другие данные, если для этого добавлена нужная логика.',
|
||||
'То есть это направление совместимо с моделью аппаратного кошелька: вы храните ключи у себя, а устройство подписывает то, что вы разрешили.',
|
||||
'Пока ещё не все валюты и сценарии доведены до готового пользовательского уровня, но архитектурно это именно путь к универсальному подписывающему устройству.',
|
||||
],
|
||||
@@ -142,7 +142,6 @@ export function render({ navigate }) {
|
||||
const heroCard = document.createElement('div');
|
||||
heroCard.className = 'card stack registration-faq-hero';
|
||||
heroCard.innerHTML = `
|
||||
<div class="badge alt">Вопросы о регистрации</div>
|
||||
<p class="auth-copy">Короткие ответы на самые частые вопросы о ключах, пароле, первом сервере и доверенных устройствах.</p>
|
||||
`;
|
||||
|
||||
@@ -174,20 +173,23 @@ export function render({ navigate }) {
|
||||
}
|
||||
|
||||
const topicsCard = document.createElement('div');
|
||||
topicsCard.className = 'card stack';
|
||||
topicsCard.className = 'stack';
|
||||
|
||||
const topicsLabel = document.createElement('p');
|
||||
topicsLabel.className = 'field-label';
|
||||
topicsLabel.className = 'nav-list__title';
|
||||
topicsLabel.textContent = 'Другие вопросы';
|
||||
|
||||
const topicsGrid = document.createElement('div');
|
||||
topicsGrid.className = 'registration-faq-grid';
|
||||
topicsGrid.className = 'nav-list';
|
||||
|
||||
REGISTRATION_FAQ_TOPICS.forEach((topic) => {
|
||||
REGISTRATION_FAQ_TOPICS.filter((topic) => topic.id !== selectedTopic.id).forEach((topic) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = topic.id === selectedTopic.id ? 'secondary-btn' : 'ghost-btn';
|
||||
button.className = 'nav-row';
|
||||
button.type = 'button';
|
||||
button.textContent = topic.shortTitle;
|
||||
const label = document.createElement('span');
|
||||
label.className = 'nav-row__label';
|
||||
label.textContent = topic.title;
|
||||
button.append(label);
|
||||
button.addEventListener('click', () => {
|
||||
state.registrationHelp.selectedTopic = topic.id;
|
||||
navigate('registration-faq-view');
|
||||
|
||||
@@ -83,7 +83,7 @@ export function render({ navigate }) {
|
||||
const blockchainRow = createKeyInfo(
|
||||
blockchainToggle,
|
||||
'Ключ blockchain',
|
||||
'Используется для подписи ваших действий и записей в блокчейне SHiNE.',
|
||||
'Используется для подписи ваших действий и записей в блокчейне Сияния.',
|
||||
);
|
||||
|
||||
const deviceRow = createKeyInfo(
|
||||
|
||||
@@ -6,7 +6,7 @@ export const pageMeta = { id: 'server-settings-view', title: 'Серверы б
|
||||
|
||||
const SERVER_FIELDS = [
|
||||
{ key: 'solanaServer', label: 'Адрес Solana сервера' },
|
||||
{ key: 'shineServerLogin', label: 'Логин сервера Сияние' },
|
||||
{ key: 'shineServerLogin', label: 'Сервер Сияния' },
|
||||
{ key: 'arweaveServer', label: 'Адрес сервера Arweave' },
|
||||
];
|
||||
|
||||
@@ -27,13 +27,9 @@ export function render({navigate, chrome}) {
|
||||
const timers = new Map();
|
||||
|
||||
const introCard = document.createElement('div');
|
||||
introCard.className = 'card stack';
|
||||
introCard.className = 'settings-intro';
|
||||
introCard.innerHTML = `
|
||||
<p class="field-label">Серверы блокчейнов и публичных данных</p>
|
||||
<p class="meta-muted">
|
||||
Здесь настраиваются Solana, SHiNE и Arweave для чтения публичных данных и доступа к блокчейну.
|
||||
Эти настройки не меняют список личных серверов доступа пользователя.
|
||||
</p>
|
||||
<p class="meta-muted">Откуда приложение читает публичные данные: Solana, Сияние и Arweave. Личные серверы доступа здесь не меняются.</p>
|
||||
`;
|
||||
|
||||
const body = document.createElement('div');
|
||||
@@ -138,7 +134,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const callTimeoutLabel = document.createElement('label');
|
||||
callTimeoutLabel.className = 'field-label';
|
||||
callTimeoutLabel.textContent = 'Таймаут пред-подключения перед звонком (мс)';
|
||||
callTimeoutLabel.textContent = 'Сколько ждать связи с сервером перед звонком (мс)';
|
||||
|
||||
const callTimeoutInput = document.createElement('input');
|
||||
callTimeoutInput.className = 'input';
|
||||
@@ -155,13 +151,13 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const callTimeoutHint = document.createElement('p');
|
||||
callTimeoutHint.className = 'meta-muted';
|
||||
callTimeoutHint.textContent = 'Перед исходящим звонком клиент проверяет и восстанавливает WS-сессию. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
|
||||
callTimeoutHint.textContent = 'Перед звонком приложение проверяет и при необходимости восстанавливает соединение с сервером. Это время ожидания такой проверки перед ошибкой «Сервер временно недоступен».';
|
||||
|
||||
callSettings.append(callTimeoutLabel, callTimeoutInput, callTimeoutHint);
|
||||
body.append(callSettings);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
actions.className = 'action-pair';
|
||||
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.className = 'ghost-btn';
|
||||
@@ -190,7 +186,7 @@ export function render({navigate, chrome}) {
|
||||
help.textContent = '?';
|
||||
help.addEventListener('click', () => {
|
||||
window.alert(
|
||||
'Текст для разработчиков: для SHiNE вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
|
||||
'Текст для разработчиков: для Сияния вводится логин серверного аккаунта. Клиент читает его PDA, берёт server_address, показывает точный https-адрес и проверяет доступность WS-канала автоматически.',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { confirmDialog } from '../components/confirm-dialog.js';
|
||||
import { createTopBar } from '../components/topbar.js';
|
||||
import { addAppLogEntry, authService, closeSavedProfile, state } from '../state.js';
|
||||
import { isDeveloperToolsEnabled } from '../services/feature-settings.js';
|
||||
import { getThemeMode, setThemeMode } from '../services/theme-service.js';
|
||||
import { openPaletteEditor } from '../components/palette-editor.js';
|
||||
|
||||
export const pageMeta = { id: 'settings-view', title: 'Настройки' };
|
||||
|
||||
@@ -40,7 +42,7 @@ export function render({navigate, chrome}) {
|
||||
const logoButton = document.createElement('button');
|
||||
logoButton.type = 'button';
|
||||
logoButton.className = 'settings-shine-logo-button';
|
||||
logoButton.setAttribute('aria-label', 'Логотип SHiNE');
|
||||
logoButton.setAttribute('aria-label', 'Логотип Сияния');
|
||||
logoButton.title = 'SHiNE';
|
||||
logoButton.innerHTML = '<img class="settings-shine-logo" src="/img/shine-logo-transparent-final_big.png" alt="SHiNE" />';
|
||||
|
||||
@@ -56,44 +58,64 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'stack settings-sections';
|
||||
const row = (id, label, hint) => `<button class="nav-row" type="button" id="${id}"><span class="nav-row__label">${label}</span>${hint ? `<span class="nav-row__hint">${hint}</span>` : ''}</button>`;
|
||||
card.innerHTML = `
|
||||
<div class="stack">
|
||||
<label for="settings-theme"><strong>Оформление</strong></label>
|
||||
<span class="meta-muted">Тема меняет только цвета и не перезагружает экран.</span>
|
||||
<select class="select" id="settings-theme">
|
||||
<option value="system">Как на устройстве</option>
|
||||
<option value="light">Дневное</option>
|
||||
<option value="dark">Ночное</option>
|
||||
</select>
|
||||
<div class="card stack settings-theme-card">
|
||||
<strong id="settings-theme-label">Оформление</strong>
|
||||
<div class="tabs tabs--auto" id="settings-theme" role="radiogroup" aria-labelledby="settings-theme-label">
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="system">Авто</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="light">День</button>
|
||||
<button type="button" class="tab-btn" role="radio" data-mode="dark">Ночь</button>
|
||||
</div>
|
||||
<span class="meta-muted">«Авто» — как на устройстве.</span>
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
${row('settings-device', 'Устройства', 'Сеансы, подключение устройств, ключи')}
|
||||
${row('settings-access-servers', 'Сервер доступа', 'Личная переписка, звонки и зашифрованные данные')}
|
||||
${row('settings-blockchain-servers', 'Серверы блокчейнов', 'Solana, Сияние и Arweave для публичных данных')}
|
||||
${row('settings-arweave-uploads', 'Файлы в блокчейне', 'Заранее загрузить файл и выбрать его потом')}
|
||||
${row('settings-remote-addblock', 'Публикация через домашний сервер', 'Когда ключа публикаций нет на этом устройстве')}
|
||||
${row('settings-language', 'Язык / Language', 'Русский')}
|
||||
</div>
|
||||
<div class="nav-list">
|
||||
<button class="nav-row nav-row--danger" type="button" id="settings-signout"><span class="nav-row__label">Выйти из аккаунта на этом устройстве</span></button>
|
||||
</div>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-device">Устройства</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-remote-addblock">Remote AddBlock через homeserver</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-access-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Сервер доступа</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Личная переписка, звонки и зашифрованные данные</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-blockchain-servers">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Серверы блокчейнов</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Solana, SHiNE и Arweave для публичных данных</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-arweave-uploads">
|
||||
<span style="display:block; text-align:left;">
|
||||
<strong>Загрузить файлы в блокчейн</strong>
|
||||
<span class="meta-muted" style="display:block; margin-top:0.2rem;">Заранее загрузить файл и выбрать его потом из истории</span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-language">Язык / Language</button>
|
||||
<button class="shine-btn shine-btn--settings" type="button" id="settings-signout">Завершить текущий сеанс</button>
|
||||
`;
|
||||
|
||||
const themeSelect = card.querySelector('#settings-theme');
|
||||
themeSelect.value = getThemeMode();
|
||||
themeSelect.addEventListener('change', () => setThemeMode(themeSelect.value));
|
||||
const themeSwitch = card.querySelector('#settings-theme');
|
||||
const syncThemeSwitch = () => {
|
||||
const mode = getThemeMode();
|
||||
themeSwitch.querySelectorAll('[data-mode]').forEach((btn) => {
|
||||
btn.setAttribute('aria-checked', String(btn.dataset.mode === mode));
|
||||
});
|
||||
};
|
||||
syncThemeSwitch();
|
||||
|
||||
const LONG_PRESS_MS = 600;
|
||||
let longPressTimer = 0;
|
||||
let longPressFired = false;
|
||||
const cancelLongPress = () => { window.clearTimeout(longPressTimer); longPressTimer = 0; };
|
||||
themeSwitch.addEventListener('pointerdown', (event) => {
|
||||
const mode = event.target.closest('[data-mode]')?.dataset.mode;
|
||||
longPressFired = false;
|
||||
if (mode !== 'light' && mode !== 'dark') return;
|
||||
longPressTimer = window.setTimeout(() => {
|
||||
longPressFired = true;
|
||||
setThemeMode(mode);
|
||||
syncThemeSwitch();
|
||||
openPaletteEditor({ theme: mode });
|
||||
}, LONG_PRESS_MS);
|
||||
});
|
||||
['pointerup', 'pointerleave', 'pointercancel'].forEach((type) => themeSwitch.addEventListener(type, cancelLongPress));
|
||||
// На телефоне долгое нажатие иначе вызывает системное меню выделения.
|
||||
themeSwitch.addEventListener('contextmenu', (event) => event.preventDefault());
|
||||
themeSwitch.addEventListener('click', (event) => {
|
||||
const mode = event.target.closest('[data-mode]')?.dataset.mode;
|
||||
if (!mode || longPressFired) return;
|
||||
setThemeMode(mode);
|
||||
syncThemeSwitch();
|
||||
});
|
||||
|
||||
card.querySelector('#settings-device').addEventListener('click', () => navigate('device-view'));
|
||||
card.querySelector('#settings-remote-addblock').addEventListener('click', () => navigate('remote-addblock-session-view'));
|
||||
@@ -107,9 +129,12 @@ export function render({navigate, chrome}) {
|
||||
|
||||
const signOutBtn = card.querySelector('#settings-signout');
|
||||
signOutBtn.addEventListener('click', async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Завершить текущий профиль на этом устройстве? Если есть другие сохранённые профили, приложение переключится на следующий.'
|
||||
);
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Выйти из аккаунта?',
|
||||
text: 'Вы выйдете на этом устройстве. Если сохранены другие профили, приложение переключится на следующий.',
|
||||
confirmLabel: 'Выйти',
|
||||
danger: true,
|
||||
});
|
||||
if (!confirmed) return;
|
||||
|
||||
signOutBtn.disabled = true;
|
||||
@@ -184,6 +209,7 @@ export function render({navigate, chrome}) {
|
||||
|
||||
screen.cleanup = () => {
|
||||
isDisposed = true;
|
||||
cancelLongPress();
|
||||
};
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { bytesToBase58 } from '../services/crypto-utils.js';
|
||||
import { extractSeed32FromPkcs8B64 } from '../services/client-key-utils.js';
|
||||
import { loadEncryptedUserSecrets } from '../services/key-vault.js';
|
||||
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Показать ключи' };
|
||||
export const pageMeta = { id: 'show-keys-view', title: 'Ключи этого устройства' };
|
||||
|
||||
export function render({navigate, chrome}) {
|
||||
const screen = document.createElement('section');
|
||||
@@ -23,19 +23,18 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Показать ключи',
|
||||
title: 'Ключи этого устройства',
|
||||
back: { label: '←', onClick: () => navigate('device-view') },
|
||||
}));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.className = 'nav-list key-list';
|
||||
|
||||
const status = document.createElement('p');
|
||||
status.className = 'meta-muted';
|
||||
status.textContent = 'Загружаем сохранённые ключи...';
|
||||
card.append(status);
|
||||
|
||||
const renderField = (id, label) => {
|
||||
const renderField = (id, label, hint = '') => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'key-card stack';
|
||||
const eyeIcon = `
|
||||
@@ -53,8 +52,8 @@ export function render({navigate, chrome}) {
|
||||
`;
|
||||
row.innerHTML = `
|
||||
<div class="row">
|
||||
<span class="field-label">${label}</span>
|
||||
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeOffIcon}</button>
|
||||
<span class="field-label">${label}${hint ? `<small class="key-card__hint">${hint}</small>` : ''}</span>
|
||||
<button class="icon-btn key-toggle-btn" type="button" data-toggle="${id}" aria-label="Показать ключ" title="Показать ключ">${eyeIcon}</button>
|
||||
</div>
|
||||
<div class="key-value key-value--compact" data-value="${id}">*****</div>
|
||||
`;
|
||||
@@ -64,9 +63,9 @@ export function render({navigate, chrome}) {
|
||||
};
|
||||
|
||||
card.append(
|
||||
renderField('root', 'root key (base58)'),
|
||||
renderField('blockchain', 'blockchain.key (base58)'),
|
||||
renderField('device', 'client key (base58)'),
|
||||
renderField('root', 'Главный ключ', 'Восстановление аккаунта и важные настройки'),
|
||||
renderField('blockchain', 'Ключ публикаций', 'Подпись ваших записей'),
|
||||
renderField('device', 'Ключ устройства', 'Вход с этого устройства'),
|
||||
);
|
||||
|
||||
const setMissingState = (id) => {
|
||||
@@ -91,8 +90,8 @@ export function render({navigate, chrome}) {
|
||||
valueEl.textContent = visible[id] ? keys[id] : '*****';
|
||||
btnEl.disabled = false;
|
||||
btnEl.innerHTML = visible[id]
|
||||
? field?._eyeIcon || ''
|
||||
: field?._eyeOffIcon || '';
|
||||
? field?._eyeOffIcon || ''
|
||||
: field?._eyeIcon || '';
|
||||
btnEl.setAttribute('aria-label', visible[id] ? 'Скрыть ключ' : 'Показать ключ');
|
||||
btnEl.title = visible[id] ? 'Скрыть ключ' : 'Показать ключ';
|
||||
};
|
||||
@@ -108,16 +107,6 @@ export function render({navigate, chrome}) {
|
||||
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'auth-footer-actions';
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.className = 'ghost-btn';
|
||||
closeButton.type = 'button';
|
||||
closeButton.textContent = 'Назад';
|
||||
closeButton.addEventListener('click', () => navigate('device-view'));
|
||||
actions.append(closeButton);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (!state.session.login || !state.session.storagePwdInMemory) {
|
||||
@@ -149,6 +138,6 @@ export function render({navigate, chrome}) {
|
||||
['root', 'blockchain', 'device'].forEach((id) => updateField(id));
|
||||
})();
|
||||
|
||||
screen.append(card, actions);
|
||||
screen.append(status, card);
|
||||
return screen;
|
||||
}
|
||||
|
||||
@@ -31,13 +31,13 @@ export function render({ navigate }) {
|
||||
loginButton.addEventListener('click', () => navigate('login-view'));
|
||||
|
||||
const registerButton = document.createElement('button');
|
||||
registerButton.className = 'shine-btn shine-btn--register';
|
||||
registerButton.className = 'secondary-btn shine-btn--register';
|
||||
registerButton.type = 'button';
|
||||
registerButton.textContent = 'Зарегистрироваться';
|
||||
registerButton.addEventListener('click', () => navigate('register-view'));
|
||||
|
||||
const languageButton = document.createElement('button');
|
||||
languageButton.className = 'shine-btn shine-btn--view';
|
||||
languageButton.className = 'text-btn start-language-link';
|
||||
languageButton.type = 'button';
|
||||
languageButton.textContent = 'Язык / Language';
|
||||
languageButton.addEventListener('click', () => {
|
||||
@@ -46,9 +46,9 @@ export function render({ navigate }) {
|
||||
});
|
||||
|
||||
const settingsButton = document.createElement('button');
|
||||
settingsButton.className = 'shine-btn shine-btn--settings';
|
||||
settingsButton.className = 'text-btn start-settings-link';
|
||||
settingsButton.type = 'button';
|
||||
settingsButton.textContent = 'Настройки';
|
||||
settingsButton.textContent = 'Настройки входа';
|
||||
settingsButton.addEventListener('click', () => navigate('entry-settings-view'));
|
||||
|
||||
actions.append(loginButton, registerButton, languageButton, settingsButton);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { authService, setAuthError, setAuthInfo, state } from '../state.js';
|
||||
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
|
||||
import { toUserMessage } from '../services/ui-error-texts.js';
|
||||
|
||||
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' };
|
||||
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Вход с другого устройства' };
|
||||
|
||||
function setStatus(statusEl, message, kind = 'info') {
|
||||
statusEl.classList.toggle('is-unavailable', kind === 'error');
|
||||
@@ -38,15 +38,14 @@ export function render({navigate, chrome}) {
|
||||
status.style.display = 'none';
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'row';
|
||||
actions.style.flexWrap = 'wrap';
|
||||
actions.className = 'stack trusted-login-actions';
|
||||
|
||||
const enableToggleBtn = document.createElement('button');
|
||||
enableToggleBtn.className = 'primary-btn';
|
||||
enableToggleBtn.type = 'button';
|
||||
|
||||
const noPasswordBtn = document.createElement('button');
|
||||
noPasswordBtn.className = 'ghost-btn';
|
||||
noPasswordBtn.className = 'secondary-btn';
|
||||
noPasswordBtn.type = 'button';
|
||||
noPasswordBtn.textContent = 'Сделать вход без пароля';
|
||||
|
||||
@@ -77,6 +76,7 @@ export function render({navigate, chrome}) {
|
||||
busy = flag;
|
||||
enableToggleBtn.disabled = flag;
|
||||
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
|
||||
noPasswordBtn.hidden = !settings.enabled || !settings.hasPassword;
|
||||
savePasswordBtn.disabled = flag;
|
||||
passwordInput.disabled = flag;
|
||||
passwordConfirmInput.disabled = flag;
|
||||
@@ -87,6 +87,7 @@ export function render({navigate, chrome}) {
|
||||
enableToggleBtn.textContent = settings.enabled
|
||||
? 'Запретить вход через другое устройство'
|
||||
: 'Разрешить вход через другое устройство';
|
||||
enableToggleBtn.className = settings.enabled ? 'destructive-btn' : 'primary-btn';
|
||||
|
||||
actions.innerHTML = '';
|
||||
actions.append(enableToggleBtn);
|
||||
@@ -183,7 +184,7 @@ export function render({navigate, chrome}) {
|
||||
});
|
||||
|
||||
chrome?.setTopbar(createTopBar({
|
||||
title: 'Настройки входа через устройство',
|
||||
title: 'Вход с другого устройства',
|
||||
back: { label: '←', onClick: () => navigate('device-pairing-view') },
|
||||
}));
|
||||
screen.append(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authService, state } from '../state.js';
|
||||
import { loadRelationsForPair, loadUserProfileCard } from '../services/user-connections.js';
|
||||
import { makeProfileLinksRoute } from '../services/shine-routes.js';
|
||||
import { navigateBack } from '../router.js';
|
||||
import { profileCardHtml, profileTileHtml } from '../components/profile-card.js';
|
||||
|
||||
export const pageMeta = { id: 'user', title: 'Профиль' };
|
||||
|
||||
@@ -24,68 +25,6 @@ function effectiveSocial(flags = {}) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
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 friendsMetricHtml(stats = {}) {
|
||||
const friends = Number(stats.friendsCount || 0);
|
||||
const closeFriends = Number(stats.closeFriendsCount || 0);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="user-profile-metric is-social-metric is-friends-combined"
|
||||
data-profile-list="friends"
|
||||
aria-label="Друзья: ${friends}; близкие друзья: ${closeFriends}"
|
||||
>
|
||||
<span class="user-profile-metric-value-combined">${closeFriends} / ${friends}</span>
|
||||
<span class="user-profile-metric-label">Друзья</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
function hasContacts(card) {
|
||||
return [card?.web, card?.phone, card?.address].some((value) => String(value || '').trim());
|
||||
}
|
||||
|
||||
function spiritualPathDetailHtml(card) {
|
||||
const value = String(card?.spiritualPath || '').trim();
|
||||
return `<div class="user-profile-detail-copy${value ? '' : ' is-muted'}">${escapeHtml(value || 'Не заполнено')}</div>`;
|
||||
}
|
||||
|
||||
function contactsDetailHtml(card) {
|
||||
const rows = [
|
||||
['Ссылки', card?.web],
|
||||
['Телефон', card?.phone],
|
||||
['Адрес', card?.address],
|
||||
].filter(([, value]) => String(value || '').trim());
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
function addIconHtml() {
|
||||
return `
|
||||
<svg class="user-profile-action-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<path d="M10.5 20.5 17 27l13-14" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function relationMenuHtml(flags = {}) {
|
||||
const current = effectiveSocial(flags);
|
||||
const rows = [
|
||||
@@ -107,7 +46,7 @@ export function render({ navigate, route, chrome }) {
|
||||
screen.className = 'stack user-profile-screen';
|
||||
|
||||
const header = createTopBar({
|
||||
title: requestedLogin || 'Профиль',
|
||||
title: 'Профиль',
|
||||
back: { label: '←', onClick: () => navigateBack() },
|
||||
});
|
||||
header.classList.add('user-profile-header');
|
||||
@@ -186,60 +125,20 @@ export function render({ navigate, route, chrome }) {
|
||||
function renderProfile() {
|
||||
if (!card) return;
|
||||
const isSelf = card.login.toLowerCase() === selfLogin.toLowerCase();
|
||||
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 displayName = fullName || card.login || 'Профиль';
|
||||
const about = String(card.about || '').trim();
|
||||
const spiritualPath = String(card.spiritualPath || '').trim();
|
||||
const contactsVisible = hasContacts(card);
|
||||
|
||||
const title = header.querySelector('.topbar__title');
|
||||
if (title) title.textContent = card.login;
|
||||
|
||||
body.innerHTML = `
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-identity">
|
||||
<div class="user-profile-full-name">${escapeHtml(displayName)}</div>
|
||||
<div class="user-profile-login">@${escapeHtml(card.login)}</div>
|
||||
</div>
|
||||
|
||||
<div class="user-profile-social-metrics">
|
||||
${friendsMetricHtml(stats)}
|
||||
${metricHtml({ kind: 'channels_owned', label: 'Каналы', value: stats.ownedPublicChannelsCount, positionClass: 'is-social-metric' })}
|
||||
${metricHtml({ kind: 'channels_following', label: 'Подписки', value: stats.followingChannelsCount, positionClass: 'is-social-metric' })}
|
||||
</div>
|
||||
|
||||
<section class="user-profile-about-card" aria-label="О себе">
|
||||
<div class="user-profile-about-title">О себе</div>
|
||||
<div class="user-profile-about-field${about ? '' : ' is-empty'}">${escapeHtml(about || 'Не заполнено')}</div>
|
||||
</section>
|
||||
|
||||
${(contactsVisible || spiritualPath) ? `
|
||||
<div class="user-profile-detail-links user-profile-detail-links--below-about" aria-label="Дополнительная информация о пользователе">
|
||||
${contactsVisible ? `<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>` : '<span></span>'}
|
||||
${spiritualPath ? `<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>` : '<span></span>'}
|
||||
</div>
|
||||
<section class="user-profile-detail-panel" id="user-profile-detail-panel" aria-live="polite" hidden></section>` : ''}
|
||||
|
||||
${!isSelf ? `
|
||||
<div class="user-profile-actions-wrap">
|
||||
<div class="user-profile-add-menu" hidden></div>
|
||||
<div class="user-profile-actions" aria-label="Действия с пользователем">
|
||||
<button type="button" class="user-profile-action-btn" data-profile-action="add" aria-label="Добавить" aria-haspopup="menu" aria-expanded="false">${addIconHtml()}</button>
|
||||
<button type="button" class="user-profile-action-btn is-links" data-profile-action="links" aria-label="Связи" title="Связи"><img src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true"></button>
|
||||
<button type="button" class="user-profile-action-btn" data-profile-action="chat" aria-label="Сообщение" title="Сообщение"><img src="/assets/icon_lichnye.png" alt="" aria-hidden="true"></button>
|
||||
</div>
|
||||
</div>` : ''}`;
|
||||
body.innerHTML = profileCardHtml({
|
||||
card,
|
||||
login: requestedLogin,
|
||||
isSelf,
|
||||
beforeTilesHtml: '<div class="user-profile-add-menu" hidden></div>',
|
||||
tilesHtml: isSelf ? '' : [
|
||||
profileTileHtml({ icon: 'user-plus', label: 'Добавить', attrs: 'data-profile-action="add" aria-haspopup="menu" aria-expanded="false"' }),
|
||||
profileTileHtml({ label: 'Связи', attrs: 'data-profile-action="links"', iconMarkup: `<img class="pf-tile-mandala" src="${SHINE_CONNECTIONS_LOGO_SRC}" alt="" aria-hidden="true">` }),
|
||||
profileTileHtml({ icon: 'message', label: 'Написать', attrs: 'data-profile-action="chat"' }),
|
||||
].join(''),
|
||||
});
|
||||
|
||||
const avatarSlot = body.querySelector('.user-profile-avatar-slot');
|
||||
avatarSlot?.append(renderUserAvatar({
|
||||
@@ -272,31 +171,6 @@ export function render({ navigate, route, chrome }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailButton = event.target.closest('[data-profile-detail]');
|
||||
if (detailButton) {
|
||||
const detailKind = detailButton.dataset.profileDetail;
|
||||
const detailPanel = body.querySelector('#user-profile-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;
|
||||
return;
|
||||
}
|
||||
|
||||
const relationButton = event.target.closest('[data-relation-kind]');
|
||||
if (relationButton) {
|
||||
if (!selfLogin) {
|
||||
|
||||
@@ -531,10 +531,10 @@ export function render({navigate, chrome}) {
|
||||
|
||||
function styleSupportInputField(field) {
|
||||
if (!field) return;
|
||||
field.style.color = '#111111';
|
||||
field.style.webkitTextFillColor = '#111111';
|
||||
field.style.caretColor = '#111111';
|
||||
field.style.backgroundColor = '#ffffff';
|
||||
field.style.color = 'var(--text-primary)';
|
||||
field.style.webkitTextFillColor = 'var(--text-primary)';
|
||||
field.style.caretColor = 'var(--accent)';
|
||||
field.style.backgroundColor = 'var(--surface)';
|
||||
}
|
||||
|
||||
function renderSupportHub() {
|
||||
@@ -1283,55 +1283,28 @@ export function render({navigate, chrome}) {
|
||||
content.innerHTML = '';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card stack';
|
||||
card.innerHTML = `
|
||||
<h2 style="margin:0 0 6px;">Кошелёк</h2>
|
||||
<p class="meta-muted">Выберите режим кошелька.</p>
|
||||
`;
|
||||
|
||||
const solanaBtn = document.createElement('button');
|
||||
solanaBtn.className = 'primary-btn';
|
||||
solanaBtn.style.width = '100%';
|
||||
solanaBtn.textContent = 'Solana кошелёк';
|
||||
solanaBtn.addEventListener('click', () => {
|
||||
void renderSolanaWallet();
|
||||
card.className = 'nav-list';
|
||||
const items = [
|
||||
['Solana кошелёк', 'Баланс SOL', () => renderSolanaWallet()],
|
||||
['Arweave кошелёк', 'Баланс AR', () => renderArweaveWallet()],
|
||||
['Предоплаченное место Сияния', 'Лимит, израсходовано и остаток', () => renderShineBlockchainWallet()],
|
||||
['Закрепление в Solana', 'Что закреплено в Solana и на сервере', () => renderSolanaPublishWallet()],
|
||||
['Поддержать проект Сияние', 'Билеты и справка', () => renderSupportHub()],
|
||||
];
|
||||
const buttons = items.map(([label, hint, open]) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'nav-row';
|
||||
btn.innerHTML = `<span class="nav-row__label"></span><span class="nav-row__hint"></span>`;
|
||||
btn.querySelector('.nav-row__label').textContent = label;
|
||||
btn.querySelector('.nav-row__hint').textContent = hint;
|
||||
btn.addEventListener('click', () => { void open(); });
|
||||
return btn;
|
||||
});
|
||||
|
||||
const arweaveBtn = document.createElement('button');
|
||||
arweaveBtn.className = 'primary-btn';
|
||||
arweaveBtn.style.width = '100%';
|
||||
arweaveBtn.textContent = 'Arweave кошелёк';
|
||||
arweaveBtn.addEventListener('click', () => {
|
||||
void renderArweaveWallet();
|
||||
});
|
||||
|
||||
const shineBchBtn = document.createElement('button');
|
||||
shineBchBtn.className = 'primary-btn';
|
||||
shineBchBtn.style.width = '100%';
|
||||
shineBchBtn.textContent = 'Предоплаченное место Сияния';
|
||||
shineBchBtn.addEventListener('click', () => {
|
||||
void renderShineBlockchainWallet();
|
||||
});
|
||||
|
||||
const solanaPublishBtn = document.createElement('button');
|
||||
solanaPublishBtn.className = 'primary-btn';
|
||||
solanaPublishBtn.style.width = '100%';
|
||||
solanaPublishBtn.textContent = 'Закрепление в Solana';
|
||||
solanaPublishBtn.addEventListener('click', () => {
|
||||
void renderSolanaPublishWallet();
|
||||
});
|
||||
|
||||
const supportBtn = document.createElement('button');
|
||||
supportBtn.className = 'primary-btn';
|
||||
supportBtn.style.width = '100%';
|
||||
supportBtn.textContent = 'Поддержать проект Сияние';
|
||||
supportBtn.addEventListener('click', () => {
|
||||
void renderSupportHub();
|
||||
});
|
||||
|
||||
const [solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn] = buttons;
|
||||
card.append(solanaBtn, arweaveBtn, shineBchBtn, solanaPublishBtn, supportBtn);
|
||||
content.append(card);
|
||||
setStatus('Выберите тип кошелька.');
|
||||
setStatus('');
|
||||
}
|
||||
|
||||
async function renderShineBlockchainWallet() {
|
||||
@@ -2051,7 +2024,7 @@ export function render({navigate, chrome}) {
|
||||
helpCard.innerHTML = `
|
||||
<summary style="cursor:pointer; font-weight:600;">Как получен этот адрес?</summary>
|
||||
<p class="meta-muted" style="margin-top:8px;">
|
||||
SHiNE берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
|
||||
Сияние берёт ваш локальный client.key и по стандарту SAWD-v1 получает из него нативный Arweave-кошелёк.
|
||||
Приватный ключ не отправляется на сервер. После первого расчёта он хранится только в зашифрованном контейнере этого устройства.
|
||||
</p>
|
||||
`;
|
||||
|
||||
+10
-1
@@ -476,7 +476,17 @@ export function navigateBack() {
|
||||
window.history.back();
|
||||
}
|
||||
|
||||
// Чужой профиль открывают из чатов, связей и каналов — подсвечиваем вкладку, откуда пришли.
|
||||
let lastToolbarTab = 'messages-list';
|
||||
|
||||
export function resolveToolbarActive(pageId) {
|
||||
if (pageId === 'user') return lastToolbarTab;
|
||||
const tab = resolveToolbarTab(pageId);
|
||||
lastToolbarTab = tab;
|
||||
return tab;
|
||||
}
|
||||
|
||||
function resolveToolbarTab(pageId) {
|
||||
if (
|
||||
pageId === 'messages-list'
|
||||
|| pageId === 'channels-list'
|
||||
@@ -510,7 +520,6 @@ export function resolveToolbarActive(pageId) {
|
||||
) return 'profile-view';
|
||||
if (pageId === 'chat-view' || pageId === 'contact-search-view') return 'messages-list';
|
||||
if (pageId === 'channel-view' || pageId === 'channel-about-view' || pageId === 'channel-donate-view' || pageId === 'channel-thread-view' || pageId === 'add-channel-view' || pageId === 'add-personal-public-chat-view') return 'channels-list';
|
||||
if (pageId === 'user') return 'messages-list';
|
||||
return 'profile-view';
|
||||
}
|
||||
|
||||
|
||||
@@ -177,14 +177,20 @@ function stopVideos(root) {
|
||||
});
|
||||
}
|
||||
|
||||
function createDownloadLink(url, label = 'Скачать') {
|
||||
function createDownloadLink(url, label = 'Скачать', { iconOnly = false } = {}) {
|
||||
const link = document.createElement('a');
|
||||
link.className = 'message-attachment-download';
|
||||
link.className = `message-attachment-download${iconOnly ? ' message-attachment-download--icon' : ''}`;
|
||||
link.href = url;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.download = '';
|
||||
link.textContent = label;
|
||||
if (iconOnly) {
|
||||
link.setAttribute('aria-label', label);
|
||||
link.title = label;
|
||||
link.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 4v12M6 10l6 6 6-6M5 20h14"/></svg>';
|
||||
} else {
|
||||
link.textContent = label;
|
||||
}
|
||||
link.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
});
|
||||
@@ -287,6 +293,7 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
|
||||
|
||||
const bindImageLayout = (img) => {
|
||||
img.addEventListener('load', () => {
|
||||
frame.classList.add('is-loaded');
|
||||
const naturalWidth = Number(img.naturalWidth || 0);
|
||||
const naturalHeight = Number(img.naturalHeight || 0);
|
||||
const isLandscape = naturalWidth > 0 && naturalHeight > 0 && naturalWidth > naturalHeight;
|
||||
@@ -326,13 +333,14 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.addEventListener('error', () => replaceWithUnavailable(slide, messageTimestampMs), { once: true });
|
||||
video.addEventListener('loadeddata', () => frame.classList.add('is-loaded'), { once: true });
|
||||
const play = document.createElement('span');
|
||||
play.className = 'message-attachment-play';
|
||||
play.textContent = '▶';
|
||||
frame.append(video, play);
|
||||
}
|
||||
|
||||
frame.append(createDownloadLink(url));
|
||||
frame.append(createDownloadLink(url, 'Скачать', { iconOnly: true }));
|
||||
return frame;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,56 @@ function pickUnit(seconds) {
|
||||
return ['year', Math.round(years)];
|
||||
}
|
||||
|
||||
// Время поста внутри дня: «14:32». День показывается отдельной плашкой.
|
||||
export function formatClockTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(ts));
|
||||
}
|
||||
|
||||
// Плашка дня в ленте: «Сегодня», «Вчера», «24 сентября», «24 сентября 2025».
|
||||
export function formatDayLabel(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
const today = new Date();
|
||||
const startOf = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
const diffDays = Math.round((startOf(today) - startOf(dt)) / 86400000);
|
||||
if (diffDays === 0) return 'Сегодня';
|
||||
if (diffDays === 1) return 'Вчера';
|
||||
const sameYear = dt.getFullYear() === today.getFullYear();
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: 'numeric', month: 'long', ...(sameYear ? {} : { year: 'numeric' }) }).format(dt);
|
||||
}
|
||||
|
||||
export function dayKey(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
return `${dt.getFullYear()}-${dt.getMonth()}-${dt.getDate()}`;
|
||||
}
|
||||
|
||||
// Время в строках списков (чаты, каналы) — коротко, как в мессенджерах:
|
||||
// сегодня «21:41», вчера «вчера», на этой неделе «пт», в этом году «24.09», раньше «24.09.25».
|
||||
export function formatListTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
if (ts >= startOfToday) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(dt);
|
||||
}
|
||||
const dayMs = 86400000;
|
||||
if (ts >= startOfToday - dayMs) return 'вчера';
|
||||
if (ts >= startOfToday - 6 * dayMs) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short' }).format(dt);
|
||||
}
|
||||
if (dt.getFullYear() === now.getFullYear()) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit' }).format(dt);
|
||||
}
|
||||
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' }).format(dt);
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '—';
|
||||
@@ -39,6 +89,8 @@ export function formatRelativeTime(timestampMs) {
|
||||
const ageSeconds = Math.max(0, (now - ts) / 1000);
|
||||
const ageDays = ageSeconds / 86400;
|
||||
|
||||
if (ageSeconds < 60) return 'только что';
|
||||
|
||||
if (ageDays < 7) {
|
||||
const [unit, value] = pickUnit(diffSeconds);
|
||||
if (rtf) return rtf.format(value, unit);
|
||||
|
||||
@@ -43,7 +43,7 @@ function wsUrlToHttpBase(wsUrl = '') {
|
||||
if (parsed.protocol === 'wss:') parsed.protocol = 'https:';
|
||||
else if (parsed.protocol === 'ws:') parsed.protocol = 'http:';
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error('Не удалось определить HTTP-адрес сервера SHiNE');
|
||||
throw new Error('Не удалось определить HTTP-адрес сервера Сияния');
|
||||
}
|
||||
parsed.pathname = '/';
|
||||
parsed.search = '';
|
||||
@@ -609,7 +609,7 @@ function suggestedPickerTypes(attachment) {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot >= 0 ? name.slice(dot) : '';
|
||||
return [{
|
||||
description: 'Файл SHiNE',
|
||||
description: 'Файл Сияния',
|
||||
accept: { [mime]: extension ? [extension] : [] },
|
||||
}];
|
||||
}
|
||||
@@ -692,7 +692,7 @@ export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = n
|
||||
}
|
||||
|
||||
export async function buildDmFileTorrentV2(attachment = {}) {
|
||||
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2');
|
||||
if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов Сияния v2');
|
||||
const context = await loadV2RootManifest(attachment);
|
||||
const pieceLayer = [];
|
||||
try {
|
||||
|
||||
@@ -55,7 +55,7 @@ export function makeKeyTransferText({ login, keys }) {
|
||||
export function parseKeyTransferText(text) {
|
||||
const raw = String(text || '').trim();
|
||||
if (!raw.startsWith(TRANSFER_PREFIX)) {
|
||||
throw new Error('Это не QR-код переноса ключей SHiNE');
|
||||
throw new Error('Это не QR-код переноса ключей Сияния');
|
||||
}
|
||||
const json = decoder.decode(base64UrlToBytes(raw.slice(TRANSFER_PREFIX.length)));
|
||||
const payload = JSON.parse(json);
|
||||
|
||||
@@ -46,7 +46,7 @@ export async function resolveShineServerByServerLogin({ serverLogin, solanaEndpo
|
||||
solanaEndpoint,
|
||||
});
|
||||
if (!parsed?.isServer) {
|
||||
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер SHiNE.`);
|
||||
throw new Error(`Логин @${cleanServerLogin} не опубликован как сервер Сияния.`);
|
||||
}
|
||||
const serverAddress = normalizeHostLike(parsed?.serverAddress || '');
|
||||
if (!serverAddress) {
|
||||
|
||||
@@ -695,7 +695,7 @@ export async function readShineUserPda({ login, solanaEndpoint }) {
|
||||
const enc = new TextEncoder();
|
||||
const [userPda] = solana.PublicKey.findProgramAddressSync([enc.encode(SHINE_USERS_USER_PDA_SEED_PREFIX), enc.encode(cleanLogin)], usersProgram);
|
||||
const accountInfo = await connection.getAccountInfo(userPda, 'confirmed');
|
||||
if (!accountInfo?.data) throw new Error(`PDA не найдена для логина «${cleanLogin}»`);
|
||||
if (!accountInfo?.data) throw new Error(`Запись аккаунта «${cleanLogin}» в Solana не найдена`);
|
||||
return {
|
||||
...parseShineUserPda(accountInfo.data),
|
||||
userPda: userPda.toBase58(),
|
||||
|
||||
@@ -1,12 +1,188 @@
|
||||
const STORAGE_KEY = 'shine-ui-theme-mode-v1';
|
||||
// Выбранная палитра и личные правки цветов (для дня и ночи отдельно).
|
||||
const PALETTE_KEY = 'shine-ui-palette-v1';
|
||||
// Итоговые значения для каждой темы: их читает скрипт в index.html до первой отрисовки,
|
||||
// чтобы при запуске не мелькала палитра по умолчанию.
|
||||
const APPLIED_KEY = 'shine-ui-palette-applied-v1';
|
||||
const MODES = new Set(['system', 'light', 'dark']);
|
||||
let sessionMode = null;
|
||||
|
||||
// Роли цветов — тот же контракт, что в docs/UI-Design/DESIGN.md (раздел 4.1) и styles/main.css.
|
||||
export const PALETTE_ROLES = [
|
||||
{ id: 'background', label: 'Фон' },
|
||||
{ id: 'surface', label: 'Поверхности: поля, меню, карточки' },
|
||||
{ id: 'surface-selected', label: 'Выбранное и лёгкое выделение' },
|
||||
{ id: 'text-primary', label: 'Основной текст' },
|
||||
{ id: 'text-secondary', label: 'Вторичный текст: время, подсказки' },
|
||||
{ id: 'border-subtle', label: 'Разделители' },
|
||||
{ id: 'border-control', label: 'Рамки полей и кнопок' },
|
||||
{ id: 'accent', label: 'Акцент: главные кнопки, ссылки' },
|
||||
{ id: 'on-accent', label: 'Текст на акцентной кнопке' },
|
||||
{ id: 'reaction-active', label: 'Поставленный лайк' },
|
||||
{ id: 'danger', label: 'Ошибки и удаление' },
|
||||
{ id: 'success', label: 'Успех' },
|
||||
{ id: 'warning', label: 'Предупреждение' },
|
||||
];
|
||||
|
||||
export const PALETTE_PRESETS = {
|
||||
club: {
|
||||
label: 'Индиго',
|
||||
dark: {
|
||||
background: '#12141f', surface: '#1c2031', 'surface-selected': '#272c45',
|
||||
'text-primary': '#eceefa', 'text-secondary': '#a0a6c2',
|
||||
'border-subtle': '#343a55', 'border-control': '#6d7396',
|
||||
accent: '#a9b4ff', 'on-accent': '#151a3d', 'reaction-active': '#ff9bb3',
|
||||
danger: '#ffa7a3', success: '#8fdab4', warning: '#e8c585',
|
||||
},
|
||||
light: {
|
||||
background: '#eef0f5', surface: '#ffffff', 'surface-selected': '#dde3f5',
|
||||
'text-primary': '#1e2233', 'text-secondary': '#5c6279',
|
||||
'border-subtle': '#d5d9e5', 'border-control': '#8a90a8',
|
||||
accent: '#4c5caa', 'on-accent': '#ffffff', 'reaction-active': '#c02a55',
|
||||
danger: '#b3261e', success: '#2c6b4a', warning: '#7d5a10',
|
||||
},
|
||||
},
|
||||
shine: {
|
||||
label: 'Бирюза',
|
||||
dark: {
|
||||
background: '#101b20', surface: '#17272d', 'surface-selected': '#213b3a',
|
||||
'text-primary': '#e8f2f1', 'text-secondary': '#98adaf',
|
||||
'border-subtle': '#2a3b40', 'border-control': '#71888a',
|
||||
accent: '#94e1ce', 'on-accent': '#102c27', 'reaction-active': '#f29aab',
|
||||
danger: '#ffaba8', success: '#94e1ce', warning: '#e6c184',
|
||||
},
|
||||
light: {
|
||||
background: '#faf7f0', surface: '#fffdf8', 'surface-selected': '#efe6d6',
|
||||
'text-primary': '#302c25', 'text-secondary': '#706658',
|
||||
'border-subtle': '#e6dfd1', 'border-control': '#978b79',
|
||||
accent: '#93511e', 'on-accent': '#fffaf3', 'reaction-active': '#a43350',
|
||||
danger: '#b13135', success: '#346a48', warning: '#845b14',
|
||||
},
|
||||
},
|
||||
mono: {
|
||||
label: 'Монохром',
|
||||
dark: {
|
||||
background: '#111212', surface: '#1c1d1d', 'surface-selected': '#2c2d2d',
|
||||
'text-primary': '#f3f3f1', 'text-secondary': '#a3a3a0',
|
||||
'border-subtle': '#2a2b2b', 'border-control': '#6f706e',
|
||||
accent: '#f3f3f1', 'on-accent': '#111212', 'reaction-active': '#ff8a9a',
|
||||
danger: '#ff9d97', success: '#9fd8a8', warning: '#e8c37e',
|
||||
},
|
||||
light: {
|
||||
background: '#ffffff', surface: '#f6f6f5', 'surface-selected': '#ebebea',
|
||||
'text-primary': '#141414', 'text-secondary': '#626262',
|
||||
'border-subtle': '#e4e4e2', 'border-control': '#8c8c8a',
|
||||
accent: '#141414', 'on-accent': '#ffffff', 'reaction-active': '#d0214a',
|
||||
danger: '#b3261e', success: '#2f6b3b', warning: '#7d5a10',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_PRESET = 'club';
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function normalizeMode(value) {
|
||||
const mode = String(value || '').trim().toLowerCase();
|
||||
return MODES.has(mode) ? mode : 'system';
|
||||
}
|
||||
|
||||
function readJson(key) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) || 'null');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(key);
|
||||
else localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// В приватном режиме палитра всё равно действует до закрытия страницы.
|
||||
}
|
||||
}
|
||||
|
||||
function cleanOverrides(raw) {
|
||||
const out = {};
|
||||
for (const role of PALETTE_ROLES) {
|
||||
const value = String(raw?.[role.id] || '').trim();
|
||||
if (HEX_COLOR.test(value)) out[role.id] = value.toLowerCase();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
let sessionPalette = null;
|
||||
|
||||
export function getPaletteSettings() {
|
||||
if (sessionPalette) return sessionPalette;
|
||||
const raw = readJson(PALETTE_KEY) || {};
|
||||
const preset = PALETTE_PRESETS[raw.preset] ? raw.preset : DEFAULT_PRESET;
|
||||
return {
|
||||
preset,
|
||||
custom: { dark: cleanOverrides(raw.custom?.dark), light: cleanOverrides(raw.custom?.light) },
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) {
|
||||
const theme = resolvedTheme === 'light' ? 'light' : 'dark';
|
||||
return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] };
|
||||
}
|
||||
|
||||
function isDefaultPalette(settings) {
|
||||
return settings.preset === DEFAULT_PRESET
|
||||
&& !Object.keys(settings.custom.dark).length
|
||||
&& !Object.keys(settings.custom.light).length;
|
||||
}
|
||||
|
||||
function applyPaletteVars(resolvedTheme) {
|
||||
const root = document.documentElement;
|
||||
const settings = getPaletteSettings();
|
||||
for (const role of PALETTE_ROLES) root.style.removeProperty(`--${role.id}`);
|
||||
root.style.removeProperty('--focus-ring');
|
||||
// Палитра по умолчанию живёт в styles/main.css — inline-переменные не нужны.
|
||||
if (isDefaultPalette(settings)) return;
|
||||
const colors = resolvePalette(resolvedTheme, settings);
|
||||
for (const [role, value] of Object.entries(colors)) root.style.setProperty(`--${role}`, value);
|
||||
root.style.setProperty('--focus-ring', colors.accent);
|
||||
}
|
||||
|
||||
export function setPaletteSettings(next) {
|
||||
const settings = {
|
||||
preset: PALETTE_PRESETS[next?.preset] ? next.preset : DEFAULT_PRESET,
|
||||
custom: { dark: cleanOverrides(next?.custom?.dark), light: cleanOverrides(next?.custom?.light) },
|
||||
};
|
||||
sessionPalette = settings;
|
||||
if (isDefaultPalette(settings)) {
|
||||
writeJson(PALETTE_KEY, null);
|
||||
writeJson(APPLIED_KEY, null);
|
||||
} else {
|
||||
writeJson(PALETTE_KEY, settings);
|
||||
writeJson(APPLIED_KEY, { dark: resolvePalette('dark', settings), light: resolvePalette('light', settings) });
|
||||
}
|
||||
return applyThemeMode();
|
||||
}
|
||||
|
||||
export function exportPalette() {
|
||||
const settings = getPaletteSettings();
|
||||
return JSON.stringify({
|
||||
preset: settings.preset,
|
||||
dark: resolvePalette('dark', settings),
|
||||
light: resolvePalette('light', settings),
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
export function importPalette(text) {
|
||||
const data = JSON.parse(String(text || ''));
|
||||
const preset = PALETTE_PRESETS[data?.preset] ? data.preset : DEFAULT_PRESET;
|
||||
const base = PALETTE_PRESETS[preset];
|
||||
const diff = (theme) => {
|
||||
const colors = cleanOverrides(data?.[theme]);
|
||||
return Object.fromEntries(Object.entries(colors).filter(([role, value]) => base[theme][role] !== value));
|
||||
};
|
||||
return setPaletteSettings({ preset, custom: { dark: diff('dark'), light: diff('light') } });
|
||||
}
|
||||
|
||||
export function getThemeMode() {
|
||||
if (sessionMode !== null) return sessionMode;
|
||||
try {
|
||||
@@ -16,14 +192,21 @@ export function getThemeMode() {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveThemeMode(mode = getThemeMode()) {
|
||||
const normalized = normalizeMode(mode);
|
||||
if (normalized !== 'system') return normalized;
|
||||
return window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
export function applyThemeMode(mode = getThemeMode()) {
|
||||
const normalized = normalizeMode(mode);
|
||||
const resolved = normalized === 'system'
|
||||
? (window.matchMedia?.('(prefers-color-scheme: light)')?.matches ? 'light' : 'dark')
|
||||
: normalized;
|
||||
const resolved = resolveThemeMode(normalized);
|
||||
document.documentElement.dataset.themeMode = normalized;
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
applyPaletteVars(resolved);
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) meta.setAttribute('content', resolvePalette(resolved).background);
|
||||
return { mode: normalized, resolved };
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const profileFieldDefs = [
|
||||
{ key: 'first_name', readKeys: ['first_name'], label: 'Имя', placeholder: 'Введите имя' },
|
||||
{ key: 'last_name', readKeys: ['last_name'], label: 'Фамилия', placeholder: 'Введите фамилию' },
|
||||
{ key: 'address', readKeys: ['address'], label: 'Адрес', placeholder: 'Город, улица, дом' },
|
||||
{ key: 'web', readKeys: ['web'], label: 'Links', placeholder: 'Сайт, профиль или другая ссылка' },
|
||||
{ key: 'web', readKeys: ['web'], label: 'Ссылки', placeholder: 'Сайт, профиль или другая ссылка' },
|
||||
{ key: 'phone', readKeys: ['phone'], label: 'Телефон', placeholder: '+7 ...' },
|
||||
{ key: 'about', readKeys: ['about'], label: 'О себе', placeholder: 'Коротко расскажите о себе', maxLength: 160, multiline: true },
|
||||
{ key: 'spiritual_path', readKeys: ['spiritual_path'], label: 'Духовный путь', placeholder: 'Расскажите о своём духовном пути, опыте, практиках и взглядах', maxLength: 5000, multiline: true },
|
||||
|
||||
Reference in New Issue
Block a user