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:
q
2026-09-26 10:05:26 +03:00
co-authored by Claude Opus 5.5
parent 6e5b57fd7c
commit f8900e531a
94 changed files with 3337 additions and 2432 deletions
+12 -4
View File
@@ -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;
}
+52
View File
@@ -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);
+3 -3
View File
@@ -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(),
+186 -3
View File
@@ -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 };
}
+1 -1
View File
@@ -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 },