SHA256
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
9.0 KiB
JavaScript
279 lines
9.0 KiB
JavaScript
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');
|
||
}
|