UI v4: новый стиль по макету V3/V4 и доводка экранов

Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий
с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки.
Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа
со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги),
сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов –
переключатель; уведомления – список как в чатах; фото – просмотр на весь экран.
Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода,
второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов),
иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса.

Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
q
2026-09-29 01:36:52 +03:00
co-authored by Claude Opus 5.5
parent 13693a0a53
commit 2e62616b46
84 changed files with 2507 additions and 1583 deletions
+13 -66
View File
@@ -1,3 +1,4 @@
import { openMediaViewer } from '../components/media-viewer.js';
import { buildArweaveDataUrl, validateArweaveTxId, validateSha256Hex } from './arweave-file-service.js';
const ATTACH_BLOCK_RE = /^<(?:SHiNE|S):(attach|att);([^>]*)>\n?/u;
@@ -177,67 +178,6 @@ function stopVideos(root) {
});
}
function createDownloadLink(url, label = 'Скачать', { iconOnly = false } = {}) {
const link = document.createElement('a');
link.className = `message-attachment-download${iconOnly ? ' message-attachment-download--icon' : ''}`;
link.href = url;
link.target = '_blank';
link.rel = 'noopener';
link.download = '';
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();
});
return link;
}
function openAttachmentViewer({ item, url, kind }) {
const root = document.getElementById('modal-root') || document.body;
const overlay = document.createElement('div');
overlay.className = 'modal attachment-viewer-modal';
overlay.innerHTML = `
<div class="attachment-viewer-card" role="dialog" aria-modal="true">
<div class="attachment-viewer-head">
<div class="attachment-viewer-title">${escapeHtml(item.name)}</div>
<button class="icon-btn attachment-viewer-close" type="button" aria-label="Закрыть">×</button>
</div>
<div class="attachment-viewer-body"></div>
<div class="attachment-viewer-actions"></div>
</div>
`;
const close = () => {
stopVideos(overlay);
overlay.remove();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) close();
});
overlay.querySelector('.attachment-viewer-close')?.addEventListener('click', close);
const body = overlay.querySelector('.attachment-viewer-body');
if (kind === 'video') {
const video = document.createElement('video');
video.className = 'attachment-viewer-media';
video.src = url;
video.controls = true;
video.autoplay = true;
body.append(video);
} else {
const img = document.createElement('img');
img.className = 'attachment-viewer-media';
img.src = url;
img.alt = item.name;
body.append(img);
}
overlay.querySelector('.attachment-viewer-actions')?.append(createDownloadLink(url));
root.append(overlay);
}
function replaceWithUnavailable(slide, messageTimestampMs) {
if (!slide || slide.dataset.unavailable === '1') return;
slide.dataset.unavailable = '1';
@@ -275,7 +215,7 @@ function createFileCard({ item, url, messageTimestampMs, slide }) {
return card;
}
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '' }) {
function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl = '', onOpen }) {
const frame = document.createElement('div');
frame.className = `message-attachment-media-frame message-attachment-media-frame--${kind}`;
frame.role = 'button';
@@ -283,12 +223,12 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
frame.title = kind === 'video' ? 'Открыть видео' : 'Открыть изображение';
frame.addEventListener('click', (event) => {
event.stopPropagation();
openAttachmentViewer({ item, url, kind });
onOpen?.();
});
frame.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
openAttachmentViewer({ item, url, kind });
onOpen?.();
});
const bindImageLayout = (img) => {
@@ -340,7 +280,6 @@ function createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewU
frame.append(video, play);
}
frame.append(createDownloadLink(url, 'Скачать', { iconOnly: true }));
return frame;
}
@@ -382,6 +321,14 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
counter.className = 'message-attachment-counter';
wrap.classList.toggle('has-single-attachment', items.length <= 1);
// Просмотр на весь экран листает все фото и видео записи, файлы пропускает.
const mediaItems = items
.map((item, i) => ({ i, kind: getAttachmentKind(item), url: buildArweaveDataUrl({ gateway, txId: item.ar }), name: item.name }))
.filter((entry) => entry.kind === 'image' || entry.kind === 'video');
const openViewer = (itemIndex) => {
openMediaViewer(mediaItems, Math.max(0, mediaItems.findIndex((entry) => entry.i === itemIndex)));
};
const render = () => {
stopVideos(wrap);
const item = items[index];
@@ -394,7 +341,7 @@ export function createAttachmentCarouselElement(attachments = [], { gateway = ''
slide.replaceChildren();
slide.append(kind === 'file'
? createFileCard({ item, url, messageTimestampMs, slide })
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl }));
: createMediaSlide({ item, url, kind, messageTimestampMs, slide, previewUrl, onOpen: () => openViewer(index) }));
prev.hidden = items.length <= 1 || index <= 0;
next.hidden = items.length <= 1 || index >= items.length - 1;
counter.hidden = items.length <= 1;