PageSourceSearch

https://voidbuilders.ru/static/js/core.js?v=9

js voidbuilders.ru collected 2026-10-05 07:05:42 UTC 11,958 bytes, 295 lines download raw bytes

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    '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
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.