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:
@@ -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