Files
SHiNE-server/shine-UI/js/services/channels-ux.js
T
qandClaude Opus 5.5 2e62616b46 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>
2026-09-29 01:36:52 +03:00

279 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const TOAST_HOST_ID = 'shine-toast-host';
const rtf = (() => {
try {
return new Intl.RelativeTimeFormat('ru', { numeric: 'always' });
} catch {
return null;
}
})();
function toNumber(value) {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
function pickUnit(seconds) {
const abs = Math.abs(seconds);
if (abs < 60) return ['second', Math.round(seconds)];
const minutes = seconds / 60;
if (Math.abs(minutes) < 60) return ['minute', Math.round(minutes)];
const hours = minutes / 60;
if (Math.abs(hours) < 24) return ['hour', Math.round(hours)];
const days = hours / 24;
if (Math.abs(days) < 30) return ['day', Math.round(days)];
const months = days / 30;
if (Math.abs(months) < 12) return ['month', Math.round(months)];
const years = months / 12;
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));
}
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);
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 '–';
const now = Date.now();
const dt = new Date(ts);
const nowDt = new Date(now);
const diffSeconds = (ts - now) / 1000;
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);
const absValue = Math.abs(value);
const suffix = value <= 0 ? 'назад' : 'через';
const labels = { second: 'сек', minute: 'мин', hour: 'ч', day: 'д' };
return `${absValue} ${labels[unit] || ''} ${suffix}`.trim();
}
if (dt.getFullYear() === nowDt.getFullYear()) {
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
}).format(dt).replace(',', ',');
}
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric',
}).format(dt);
}
function ensureToastHost() {
let host = document.getElementById(TOAST_HOST_ID);
if (host) return host;
host = document.createElement('div');
host.id = TOAST_HOST_ID;
host.className = 'toast-host';
document.body.append(host);
return host;
}
export function showToast(message, { kind = 'success', timeoutMs = 2500 } = {}) {
const text = String(message || '').trim();
if (!text) return;
const host = ensureToastHost();
const toast = document.createElement('div');
toast.className = `toast toast--${kind}`;
toast.textContent = text;
host.append(toast);
requestAnimationFrame(() => {
toast.classList.add('is-visible');
});
const hide = () => {
toast.classList.remove('is-visible');
toast.classList.add('is-hiding');
setTimeout(() => toast.remove(), 220);
};
setTimeout(hide, Math.max(1200, Number(timeoutMs) || 2500));
}
export function softHaptic(duration = 15) {
try {
if (navigator?.vibrate) navigator.vibrate(Math.max(5, Math.min(30, Number(duration) || 15)));
} catch {
// ignore
}
}
export function animatePress(el) {
if (!el) return;
el.classList.remove('is-springing');
// force reflow
// eslint-disable-next-line no-unused-expressions
el.offsetWidth;
el.classList.add('is-springing');
}
const CHANNEL_NOTIF_KEY = 'shine-channels-notify-v1';
export function readChannelNotificationsState() {
try {
const raw = localStorage.getItem(CHANNEL_NOTIF_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
} catch {
// ignore
}
return {};
}
export function writeChannelNotificationsState(nextState) {
try {
localStorage.setItem(CHANNEL_NOTIF_KEY, JSON.stringify(nextState || {}));
} catch {
// ignore
}
}
export function makeAuthorLabel(login, localNumber) {
const cleanLogin = String(login || 'автор');
const n = Number(localNumber);
if (!Number.isFinite(n) || n < 1) return cleanLogin;
return `${cleanLogin} · #${n}`;
}
export function createSkeletonCard(className = '') {
const card = document.createElement('div');
card.className = `card skeleton-card ${className}`.trim();
card.innerHTML = `
<div class="skeleton-line w-40"></div>
<div class="skeleton-line w-90"></div>
<div class="skeleton-line w-70"></div>
`;
return card;
}
export function normalizeChannelDescription(value) {
const text = String(value == null ? '' : value).trim().replace(/\s+/g, ' ');
if (!text) return '';
const chars = Array.from(text);
if (chars.length <= 200) return text;
return chars.slice(0, 200).join('');
}
function fallbackCopyText(text) {
const ta = document.createElement('textarea');
ta.value = String(text || '');
ta.setAttribute('readonly', 'readonly');
ta.style.position = 'fixed';
ta.style.opacity = '0';
ta.style.pointerEvents = 'none';
document.body.append(ta);
ta.focus();
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return !!ok;
}
export async function shareOrCopyLink({ title = '', text = '', url = '' }) {
const link = String(url || '').trim();
if (!link) {
throw new Error('Ссылка для передачи не подготовлена.');
}
const payload = { title: String(title || '').trim(), text: String(text || '').trim(), url: link };
if (navigator?.share) {
try {
await navigator.share(payload);
return 'shared';
} catch (error) {
if (error?.name === 'AbortError') return 'cancelled';
// fallback to copy path
}
}
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(link);
return 'copied';
}
if (fallbackCopyText(link)) {
return 'copied';
}
throw new Error('Не удалось передать ссылку.');
}
export async function longPressFeel(el, delayMs = 130) {
const node = el instanceof Element ? el : null;
if (node) node.classList.add('is-long-press');
await new Promise((resolve) => setTimeout(resolve, Math.max(60, Number(delayMs) || 130)));
if (node) node.classList.remove('is-long-press');
}