1/* ============================================================ 2 VoidBuilders · core.js â ÑдÑо: ÑÑилиÑÑ, ÑоÑÑÑ, модалки, 3 ÑоÑÑÐµÑ Ð²ÐºÐ»Ð°Ð´Ð¾Ðº, WebSocket-Ñ Ð°Ð±, ÑоÑÑоÑние пÑÐ¸Ð»Ð¾Ð¶ÐµÐ½Ð¸Ñ 4 ============================================================ */ 5(() => { 6 'use strict'; 7 8 const $ = (sel, root = document) => root.querySelector(sel); 9 const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); 10 11 /* ---------- УÑилиÑÑ ---------- */ 12 const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ 13 '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' 14 }[c])); 15 16 const avatarURL = (name, size = 64) => 17 `https://minotar.net/helm/${encodeURIComponent(String(name || 'Steve'))}/${size}.png`; 18 19 const avatarHTML = (name, cls = '') => { 20 const n = String(name || '?'); 21 return `<span class="avatar ${cls}" data-letter="${esc(n.charAt(0).toUpperCase())}">` + 22 `<img src="${avatarURL(n, 64)}" alt="" loading="lazy" onerror="this.classList.add('broken')"></span>`; 23 }; 24 25 const fmtClock = (src) => { 26 const d = src instanceof Date ? src : new Date(src); 27 return isNaN(d) ? '' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); 28 }; 29 const fmtDate = (src) => { 30 const d = src instanceof Date ? src : new Date(src); 31 return isNaN(d) ? '' : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' }); 32 }; 33 const dayKey = (src) => { 34 const d = src instanceof Date ? src : new Date(src); 35 return isNaN(d) ? '' : `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`; 36 }; 37 const timeAgo = (src) => { 38 const d = src instanceof Date ? src : new Date(src || ''); 39 if (!src || isNaN(d)) return 'â'; 40 const s = Math.floor((Date.now() - d.getTime()) / 1000); 41 if (s < 45) return 'ÑолÑко ÑÑо'; 42 const m = Math.floor(s / 60); 43 if (m < 60) return `${m} мин назад`; 44 const h = Math.floor(m / 60); 45 if (h < 24) return `${h} Ñ Ð½Ð°Ð·Ð°Ð´`; 46 const dd = Math.floor(h / 24); 47 if (dd < 30) return `${dd} дн назад`; 48 return fmtDate(d); 49 }; 50 51 async function api(url, { method = 'GET', form = null } = {}) { 52 const opt = method === 'GET' ? {} : { method, body: form }; 53 const res = await fetch(url, opt); 54 if (!res.ok) throw new Error(`HTTP ${res.status}`); 55 return res.json(); 56 } 57 58 /* ---------- ТоÑÑÑ ---------- */ 59 const TICO = { ok: 'â', err: 'â', info: 'i' }; 60 function toast(text, type = 'info', ms = 3600) { 61 const wrap = $('#toasts'); 62 if (!wrap) return; 63 const el = document.createElement('div'); 64 el.className = `toast t-${type}`; 65 el.innerHTML = `<span class="toast-ico">${TICO[type] || TICO.info}</span><span>${esc(text)}</span>`; 66 wrap.appendChild(el); 67 requestAnimationFrame(() => el.classList.add('show')); 68 setTimeout(() => { el.classList.remove('show'); setTimeout(() => el.remove(), 320); }, ms); 69 } 70 71 /* ---------- ÐодалÑнÑе окна ---------- */ 72 function openModal(el) { 73 if (!el) return; 74 el.classList.add('open'); 75 document.body.classList.add('modal-lock'); 76 const f = el.querySelector('input,textarea,button:not(.dialog-x)'); 77 setTimeout(() =>
77 f && f.focus(), 90); 78 } 79 function closeModal(el) { 80 if (!el) return; 81 el.classList.remove('open'); 82 if (!$('.modal.open')) document.body.classList.remove('modal-lock'); 83 } 84 function confirmDialog({ title = 'ÐодÑвеÑждение', text = '', ok = 'УдалиÑÑ' } = {}) { 85 const m = $('#confirm-modal'); 86 if (!m) return Promise.resolve(window.confirm(text)); 87 $('#cf-title').textContent = title; 88 $('#cf-text').textContent = text; 89 $('#cf-ok').textContent = ok; 90 return new Promise((resolve) => { m._resolve = resolve; openModal(m); }); 91 } 92 function _settleConfirm(val) { 93 const m = $('#confirm-modal'); 94 if (!m || !m._resolve) return; 95 closeModal(m); 96 const r = m._resolve; m._resolve = null; r(val); 97 } 98 99 /* ---------- СоÑÑоÑние ---------- */ 100 const state = { 101 user: '', 102 ws: null, 103 wsState: 'offline', // connecting | online | offline 104 wsHandlers: new Set(), 105 wsStateHandlers: new Set(), 106 unread: Object.create(null), // ÑÐµÐ»Ñ -> кол-во непÑоÑиÑаннÑÑ 107 friends: [], // кÑÑ ÑпиÑка дÑÑзей 108 }; 109 110 const getUser = () => (localStorage.getItem('username') || '').trim(); 111 const isAdmin = () => state.user === 'Ne0n_Cat'; 112 113 /* ---------- WebSocket-Ñ Ð°Ð± ---------- */ 114 function setWsState(s) { 115 if (state.wsState === s) return; 116 state.wsState = s; 117 state.wsStateHandlers.forEach((fn) => { try { fn(s); } catch (e) {} }); 118 } 119 function connectWS() { 120 if (!state.user) return; 121 disconnectWS(); 122 setWsState('connecting'); 123 const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; 124 let ws; 125 try { 126 ws = new WebSocket(`${proto}//${location.host}/ws/${encodeURIComponent(state.user)}`); 127 } catch (e) { setWsState('offline'); return; } 128 state.ws = ws; 129 ws.onopen = () => setWsState('online'); 130 ws.onmessage = (ev) => { 131 let data;
132 try { data = JSON.parse(ev.data); } catch (e) { return; } 133 state.wsHandlers.forEach((fn) => { try { fn(data); } catch (e) { console.error(e); } }); 134 }; 135 ws.onclose = (ev) => { 136 if (state.ws !== ws) return; 137 setWsState('offline'); 138 // 4401 â ÑеÑÐ²ÐµÑ Ð¾Ñклонил подклÑÑение: ÑеÑÑии неÑ, она иÑÑекла или бÑла 139 // Ñоздана до ввода Ñокенов. СбÑаÑÑваем ÐµÑ Ð¸ ÑÑÐ°Ð·Ñ Ð¿Ñедлагаем войÑи заново. 140 if (ev && ev.code === 4401) { 141 localStorage.removeItem('username'); 142 localStorage.removeItem('token'); 143 document.cookie = 'vb_token=; Max-Age=0; path=/'; 144 state.user = ''; 145 updateAuthUI(); 146 toast('СеÑÑÐ¸Ñ Ð¸ÑÑекла â войдиÑе заново', 'err', 6000); 147 const am = $('#auth-modal'); 148 if (am) openModal(am); 149 return; 150 } 151 if (state.user) setTimeout(connectWS, 4000); 152 }; 153 ws.onerror = () => { try { ws.close(); } catch (e) {} }; 154 } 155 function disconnectWS() { 156 const ws = state.ws; 157 state.ws = null; 158 if (ws) { ws.onclose = null; try { ws.close(); } catch (e) {} } 159 setWsState('offline'); 160 } 161 const onMessage = (fn) => state.wsHandlers.add(fn); 162 const onWsState = (fn) => { state.wsStateHandlers.add(fn); fn(state.wsState); }; 163 function sendWS(obj) { 164 if (state.ws && state.ws.readyState === WebSocket.OPEN) { state.ws.send(JSON.stringify(obj)); return true; } 165 return false; 166 } 167 168 /* ---------- ÐепÑоÑиÑаннÑе ---------- */ 169 function bumpUnread(target, delta = 1) { 170 state.unread[target] = Math.max(0, (state.unread[target] || 0) + delta); 171 const total = Object.values(state.unread).reduce((a, b) => a + b, 0); 172 const badge = $('#bn-badge-chat'); 173 if (badge) { 174 badge.textContent = total > 99 ? '99+' : total; 175 badge.classList.toggle('show', total > 0); 176 } 177 document.title = total > 0 ? `(${total}
177) VoidBuilders` : document.title.replace(/^\(\d+\)\s*/, ''); 178 } 179 const clearUnread = (t) => { if (state.unread[t]) bumpUnread(t, -state.unread[t]); }; 180 181 /* ---------- РоÑÑÐµÑ Ð²ÐºÐ»Ð°Ð´Ð¾Ðº ---------- */ 182 const viewHooks = {}; 183 function switchTab(id, opts = {}) { 184 let found = false; 185 $$('.view').forEach((v) => { 186 const on = v.id === `view-${id}`; 187 v.classList.toggle('hidden', !on); 188 if (on) found = true; 189 }); 190 if (!found) return false; 191 $$('[data-tab]').forEach((b) => b.classList.toggle('active', b.dataset.tab === id)); 192 history.replaceState(null, '', `#${id}`); 193 if (!opts.silent && viewHooks[id]) { try { viewHooks[id](); } catch (e) { console.error(e); } } 194 window.scrollTo(0, 0); 195 return true; 196 } 197 const onView = (id, fn) => { viewHooks[id] = fn; }; 198 199 /* ---------- ÐвÑоÑизаÑÐ¸Ñ / UI-ÑоÑÑоÑние ---------- */ 200 function updateAuthUI() { 201 state.user = getUser(); 202 const logged = !!state.user; 203 document.body.classList.toggle('is-guest', !logged); 204 document.body.classList.toggle('is-auth', logged); 205 $$('.js-user-name').forEach((el) => { el.textContent = state.user; }); 206 $$('.js-user-avatar').forEach((el) => { el.src = avatarURL(state.user || 'Steve', 64); }); 207 if (logged && !state.ws) connectWS(); 208 if (!logged) disconnectWS(); 209 } 210 function login(nick, token) { 211 localStorage.setItem('username', nick); 212 // СеÑвеÑÐ½Ð°Ñ ÑеÑÑиÑ: Ñокен кладÑм и в localStorage, и в cookie vb_token â 213 // cookie авÑомаÑиÑеÑки ÑÑ Ð¾Ð´Ð¸Ñ Ñ WebSocket-Ñ ÐµÐ½Ð´Ñейком и fetch-запÑоÑами. 214 if (token) { 215 localStorage.setItem('token', token); 216 const secure = location.protocol === 'https:' ? '; Secure' : ''; 217 document.cookie = `vb_token=${encodeURIComponent(token)}; path=/; SameSite=Lax${secure}`; 218 } 219 state.unread = Object.create(null); 220 updateAuthUI(); 221 toast(`ÐобÑо пожаловаÑÑ, ${nick}!`, 'ok'); 222 } 223 function logout() { 224 localStorage.removeItem('username'); 225 localStorage.removeItem('token'); 226 document.cookie = 'vb_token=; Max-Age=0; path=/'; 227 state.user = ''; 228 disconnectWS(); 229 location.href = '/'; 230 } 231 232 /* ---------- ÐлобалÑное делегиÑование ---------- */ 233 document.addEventListener('click', (e) => { 234 const tab = e.target.closest('[data-tab]'); 235 if (tab && !e.target.closest('.conv')) { e.preventDefault(); switchTab(tab.dataset.tab); return; } 236 const closer = e.target.closest('[data-close-modal]'); 237 if (closer) { closeModal(closer.closest('.modal')); return; } 238 if (e.target.closest('#cf-ok')) { _settleConfirm(true); return; } 239 if (e.target.closest('#cf-cancel')) { _settleConfirm(false); return; } 240 if (e.target.closest('.js-logout')) { logout(); return; } 241 const au = e.target.closest('.js-open-auth'); 242 if (au) { e.preventDefault(); openModal($('#auth-modal')); return; } 243 const gp = e.target.closest('.js-go-profile'); 244 if (gp) { 245 const u = getUser(); 246 if (u) location.href = `/profile/${encodeURIComponent(u)}`; 247 else openModal($('#auth-modal')); 248 return; 249 } 250 if (e.target.classList && e.target.classList.contains('modal') && e.target.id !== 'confirm-modal') { 251 closeModal(e.target); // клик по заÑÐµÐ¼Ð½ÐµÐ½Ð¸Ñ 252 } 253 }); 254 document.addEventListener('keydown', (e) => { 255 if (e.key !== 'Escape') return; 256 const m = $('.modal.open'); 257 if (!m) return; 258 if (m.id === 'confirm-modal') _settleConfirm(false); 259 else closeModal(m); 260 }); 261 262 /* ---------- ÐниÑиализаÑÐ¸Ñ ---------- */ 263 document.addEventListener('DOMContentLoaded', () => { 264 updateAuthUI(); 265 const yearEl = $('#year'); 266 if (yearEl) yearEl.textContent = String(new Date().getFullYear()); 267 const hash = (location.hash || '').replace('#', ''); 268 const initial = ['main', 'forum', 'friends', 'chat'].includes(hash) ? hash : 'main'; 269 switchTab(initial, { silent: true }); 270 const hook = viewHooks[initial]; 271 if (hook) setTimeout(hook, 0); 272 }); 273 274 const pluralRu = (n, forms) => { 275 // forms: ['ÑкаÑивание', 'ÑкаÑиваниÑ', 'ÑкаÑиваний'] 276 const a = Math.abs(n) % 100, b = a % 10; 277 if (a > 10 && a < 20) return forms[2]; 278 if (b > 1 && b < 5) return forms[1]; 279 if (b === 1) return forms[0]; 280 return forms[2]; 281 }; 282 283 window.VB = { 284 $, $$, esc, pluralRu, api, state, 285 avatarURL, avatarHTML, fmtClock, fmtDate, dayKey, timeAgo,
286 toast, openModal, closeModal, confirmDialog, 287 connectWS, disconnectWS, onMessage, onWsState, sendWS, 288 bumpUnread, clearUnread, 289 switchTab, onView, 290 updateAuthUI, login, logout, 291 getUser: () => state.user, 292 isAdmin, 293 }; 294})(); 295
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.