Files
SHiNE-server/shine-UI/js/services/channels-ux.js
T

212 lines
5.9 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)];
}
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 (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');
}