SHA256
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:
@@ -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;
|
||||
|
||||
@@ -11,8 +11,7 @@ function escapeHtml(text) {
|
||||
|
||||
export function openAuthRequiredModal({
|
||||
title = 'Нужен вход',
|
||||
text = 'Эта часть доступна после входа в систему.',
|
||||
startRoute = 'start-view',
|
||||
text = 'Эта часть доступна после входа.',
|
||||
} = {}) {
|
||||
const root = document.getElementById('modal-root');
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
@@ -25,18 +24,21 @@ export function openAuthRequiredModal({
|
||||
<h3 class="modal-title">${escapeHtml(title)}</h3>
|
||||
<p class="meta-muted" style="white-space: pre-wrap; line-height: 1.45;">${escapeHtml(text)}</p>
|
||||
<div class="form-actions-grid">
|
||||
<button class="secondary-btn" type="button" id="auth-required-close">Закрыть</button>
|
||||
<button class="primary-btn" type="button" id="auth-required-start">На старт</button>
|
||||
<button class="secondary-btn" type="button" id="auth-required-login">Войти</button>
|
||||
<button class="primary-btn" type="button" id="auth-required-register">Зарегистрироваться</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const close = () => { root.innerHTML = ''; };
|
||||
root.querySelector('#auth-required-close')?.addEventListener('click', close);
|
||||
root.querySelector('#auth-required-start')?.addEventListener('click', () => {
|
||||
root.querySelector('#auth-required-login')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate(startRoute);
|
||||
navigate('login-view');
|
||||
});
|
||||
root.querySelector('#auth-required-register')?.addEventListener('click', () => {
|
||||
close();
|
||||
navigate('register-view');
|
||||
});
|
||||
root.querySelector('#auth-required-modal')?.addEventListener('click', (event) => {
|
||||
if (event.target?.id === 'auth-required-modal') close();
|
||||
|
||||
@@ -35,6 +35,21 @@ export function formatClockTime(timestampMs) {
|
||||
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit' }).format(new Date(ts));
|
||||
}
|
||||
|
||||
const SHORT_MONTHS = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
||||
|
||||
// Дата записи в ряду действий: сегодня «10:24», раньше «25 сен, 10:24», в прошлом году «25 сен 2025».
|
||||
export function formatPostStamp(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '';
|
||||
const dt = new Date(ts);
|
||||
const now = new Date();
|
||||
const clock = formatClockTime(ts);
|
||||
if (dt.toDateString() === now.toDateString()) return clock;
|
||||
const day = `${dt.getDate()} ${SHORT_MONTHS[dt.getMonth()]}`;
|
||||
if (dt.getFullYear() !== now.getFullYear()) return `${day} ${dt.getFullYear()}`;
|
||||
return `${day}, ${clock}`;
|
||||
}
|
||||
|
||||
// Плашка дня в ленте: «Сегодня», «Вчера», «24 сентября», «24 сентября 2025».
|
||||
export function formatDayLabel(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
@@ -80,7 +95,7 @@ export function formatListTime(timestampMs) {
|
||||
|
||||
export function formatRelativeTime(timestampMs) {
|
||||
const ts = toNumber(timestampMs);
|
||||
if (!ts) return '—';
|
||||
if (!ts) return '–';
|
||||
|
||||
const now = Date.now();
|
||||
const dt = new Date(ts);
|
||||
|
||||
@@ -16,15 +16,38 @@ export const PALETTE_ROLES = [
|
||||
{ id: 'text-secondary', label: 'Вторичный текст: время, подсказки' },
|
||||
{ id: 'border-subtle', label: 'Разделители' },
|
||||
{ id: 'border-control', label: 'Рамки полей и кнопок' },
|
||||
{ id: 'surface-raised', label: 'Приподнятое: поля ввода, наведение' },
|
||||
{ id: 'accent', label: 'Акцент: главные кнопки, ссылки' },
|
||||
{ id: 'action', label: 'Вторичное действие: «Ответить», ссылки' },
|
||||
{ id: 'bubble-out', label: 'Исходящее сообщение' },
|
||||
{ id: 'on-accent', label: 'Текст на акцентной кнопке' },
|
||||
{ id: 'reaction-active', label: 'Поставленный лайк' },
|
||||
{ id: 'danger', label: 'Ошибки и удаление' },
|
||||
{ id: 'success', label: 'Успех' },
|
||||
{ id: 'warning', label: 'Предупреждение' },
|
||||
{ id: 'info', label: 'Информация, подсказки о процессе' },
|
||||
];
|
||||
|
||||
export const PALETTE_PRESETS = {
|
||||
// «Сияние» – цветовые роли макета V3/V4: тёмная тема из спецификации,
|
||||
// светлая подобрана отдельно, а не инверсией.
|
||||
aurora: {
|
||||
label: 'Сияние',
|
||||
dark: {
|
||||
background: '#121419', surface: '#1c1f27', 'surface-selected': '#302b43', 'surface-raised': '#272a34',
|
||||
'text-primary': '#f1f1f5', 'text-secondary': '#a8abb8',
|
||||
'border-subtle': '#303440', 'border-control': '#454858',
|
||||
accent: '#b8aef5', 'on-accent': '#211c34', action: '#c6bff5', 'bubble-out': '#39324d',
|
||||
'reaction-active': '#b8aef5', danger: '#f2a1ad', success: '#91d4b0', warning: '#edc487', info: '#a8c9f3',
|
||||
},
|
||||
light: {
|
||||
background: '#f3f2f7', surface: '#ffffff', 'surface-selected': '#e8e3fa', 'surface-raised': '#ecebf2',
|
||||
'text-primary': '#1d1b26', 'text-secondary': '#5f5b6e',
|
||||
'border-subtle': '#e0dde8', 'border-control': '#a19db2',
|
||||
accent: '#5d4fc4', 'on-accent': '#ffffff', action: '#5244b5', 'bubble-out': '#e6e0fb',
|
||||
'reaction-active': '#5d4fc4', danger: '#b3304a', success: '#2d7552', warning: '#8a5a0e', info: '#2f5f9e',
|
||||
},
|
||||
},
|
||||
club: {
|
||||
label: 'Индиго',
|
||||
dark: {
|
||||
@@ -138,7 +161,7 @@ for (const [id, label, ...colors] of EXTRA_PRESETS) {
|
||||
PALETTE_PRESETS[id] = makePreset(label, colors.slice(0, 7), colors.slice(7));
|
||||
}
|
||||
|
||||
const DEFAULT_PRESET = 'club';
|
||||
const DEFAULT_PRESET = 'aurora';
|
||||
const HEX_COLOR = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function normalizeMode(value) {
|
||||
@@ -184,9 +207,20 @@ export function getPaletteSettings() {
|
||||
};
|
||||
}
|
||||
|
||||
// Роли, которых нет в старых пресетах, выводятся из основных цветов.
|
||||
function withDerivedRoles(colors, theme) {
|
||||
return {
|
||||
'surface-raised': mixHex(colors.surface, colors['text-primary'], 0.07),
|
||||
action: mixHex(colors.accent, colors['text-primary'], 0.18),
|
||||
'bubble-out': mixHex(colors.surface, colors.accent, theme === 'light' ? 0.16 : 0.22),
|
||||
info: theme === 'light' ? '#2f5f9e' : '#a8c9f3',
|
||||
...colors,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePalette(resolvedTheme, settings = getPaletteSettings()) {
|
||||
const theme = resolvedTheme === 'light' ? 'light' : 'dark';
|
||||
return { ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] };
|
||||
return withDerivedRoles({ ...PALETTE_PRESETS[settings.preset][theme], ...settings.custom[theme] }, theme);
|
||||
}
|
||||
|
||||
function isDefaultPalette(settings) {
|
||||
|
||||
Reference in New Issue
Block a user