const API = location.origin; let token = sessionStorage.getItem('authToken'); let currentUser = null; function hdr() { return { 'X-Auth-Token': token }; } function hdrJson() { return { 'Content-Type': 'application/json', 'X-Auth-Token': token }; } function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } function thumbSrc(p) { if (!p) return p; if (p.startsWith('/uploads/')) return `${API}/uploads/thumb/${p.slice('/uploads/'.length)}`; if (p.includes('/api/files/')) return p + (p.includes('?') ? '&' : '?') + 'thumb=1'; return p; } // --- Auth check --- async function checkAuth() { if (!token) { location.href = 'login.html'; return false; } try { const res = await fetch(`${API}/api/auth/me`, { headers: hdr() }); if (!res.ok) { cleanupSession(); return false; } currentUser = await res.json(); return true; } catch { cleanupSession(); return false; } } function cleanupSession() { sessionStorage.removeItem('authToken'); token = null; currentUser = null; location.href = 'login.html'; } function isAdmin() { return currentUser && currentUser.role === 'admin'; } async function requireAdminPage() { if (!(await checkAuth())) return false; if (!isAdmin()) { location.href = 'journal.html'; return false; } return true; } async function doLogout() { try { await fetch(`${API}/api/auth/logout`, { method: 'POST', headers: hdr() }); } catch {} cleanupSession(); } // --- Sidebar --- function buildSidebar(active) { const el = document.getElementById('sidebar'); if (!el) return; const base = [ { page: 'dashboard', label: 'Дашборд', icon: 'layout-dashboard' }, { page: 'journal', label: 'Журнал', icon: 'book-open' }, { page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true }, { page: 'students', label: 'Ученики', icon: 'graduation-cap' }, { page: 'groups', label: 'Группы', icon: 'users' }, { page: 'photos', label: 'Фото', icon: 'Image' }, { page: 'files', label: 'Файлы', icon: 'folder' }, { page: 'links', label: 'Ссылки', icon: 'link' }, { page: 'trash', label: 'Корзина', icon: 'trash-2' }, ]; const adminNav = [ { page: 'modules', label: 'Темы модулей', icon: 'layers' }, { page: 'branches', label: 'Филиалы', icon: 'building-2' }, { page: 'users', label: 'Пользователи', icon: 'user-cog' }, { page: 'worker', label: 'Воркер ИИ', icon: 'bot' }, { page: 'audit', label: 'Аудит', icon: 'scroll-text' }, { page: 'bans', label: 'Блокировки', icon: 'shield-off' }, { page: 'settings', label: 'Настройки', icon: 'settings' }, ]; const userName = currentUser?.name || currentUser?.username || ''; const roleLabel = isAdmin() ? 'Администратор' : 'Тутор'; const renderItem = n => { const isAct = n.page === active; const badgeHtml = n.badge ? ` 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}` : ''; return ` ${n.label} ${badgeHtml} `; }; el.innerHTML = `
${esc(userName)}
${roleLabel}
Уведомления 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread} На главную
`; renderIcons(); loadVersion(); loadBrand(); initNotifications(); } async function loadBrand() { const logoEl = document.getElementById('sidebarLogo'); const nameEl = document.getElementById('sidebarName'); if (!logoEl || !nameEl) return; try { const res = await fetch(`${API}/api/public-settings`); if (!res.ok) return; const s = await res.json(); if ((s.system_name || '').trim()) nameEl.textContent = s.system_name; if (s.system_logo) { const img = document.createElement('img'); img.src = s.system_logo; img.alt = ''; logoEl.classList.add('has-logo'); logoEl.appendChild(img); } } catch {} } async function loadVersion() { const el = document.getElementById('sidebarVersion'); if (!el) return; try { const res = await fetch(`${API}/version.json?t=${Date.now()}`); if (!res.ok) { el.textContent = ''; return; } const v = await res.json(); if (!v.short) { el.textContent = ''; return; } const d = (v.date || '').slice(0, 10); el.textContent = `v${v.short}${d ? ' · ' + d : ''}`; el.title = `${v.short} · ${v.date || ''}`; } catch { el.textContent = ''; } } // --- Notifications --- const NOTIFY_ICONS = { 'entry.new': 'book-open', 'entry.ai.corrected': 'sparkles', 'entry.ai.error': 'bot', 'photo.job.done': 'image', 'photo.job.error': 'image-off', 'ip.ban': 'shield-off', 'backup.restore': 'database', 'backup.create': 'download', 'system.test': 'send', }; const NOTIFY_LEVELS = { warning: 'Предупреждение', critical: 'Важно', info: '' }; let notifyList = []; let notifyUnread = 0; let notifyStream = null; let notifyReconnectTimer = null; let desktopNotify = localStorage.getItem('notifyDesktop') === '1'; function notifyIcon(type) { return NOTIFY_ICONS[type] || 'bell'; } function attrEsc(s) { return esc(String(s === null || s === undefined ? '' : s).replace(/"/g, '"')); } function notifyTime(ts) { const d = new Date(ts); if (Number.isNaN(d.getTime())) return ''; const diff = Date.now() - d.getTime(); if (diff < 60000) return 'только что'; if (diff < 3600000) return `${Math.floor(diff / 60000)} мин назад`; if (diff < 86400000) return `${Math.floor(diff / 3600000)} ч назад`; return d.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); } function setNotifyUnread(n) { notifyUnread = Number(n) || 0; const badge = document.getElementById('notifyBadge'); if (badge) { badge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread); badge.hidden = notifyUnread <= 0; } const navBadge = document.getElementById('sidebarNotifyBadge'); if (navBadge) { navBadge.textContent = notifyUnread > 99 ? '99+' : String(notifyUnread); navBadge.hidden = notifyUnread <= 0; } } function notifyItemHtml(n, expanded) { const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : ''; return ` ${esc(n.title)} ${n.body ? `${esc(n.body)}` : ''} ${esc(notifyTime(n.created_at))} `; } function notifyPanelEl() { let panel = document.getElementById('notifyPanel'); if (panel) return panel; panel = document.createElement('div'); panel.className = 'notify-panel'; panel.id = 'notifyPanel'; panel.hidden = true; document.body.appendChild(panel); return panel; } function renderNotifyPanel() { const panel = notifyPanelEl(); if (!panel) return; const items = notifyList.slice(0, 12); panel.innerHTML = `
Уведомления
${items.length ? items.map(n => notifyItemHtml(n, false)).join('') : '
Пока уведомлений нет
'}
Все уведомления${notifyUnread ? ` (${notifyUnread})` : ''}
`; renderIcons(); } async function loadNotifications() { try { const res = await fetch(`${API}/api/notifications?limit=20`, { headers: hdr() }); if (!res.ok) return; const data = await res.json(); notifyList = data.items || []; setNotifyUnread(data.unread); renderNotifyPanel(); } catch {} } function toggleNotifyPanel(force) { const panel = notifyPanelEl(); if (!panel) return; const open = force === undefined ? panel.hidden : !!force; panel.hidden = !open; if (open) { renderNotifyPanel(); loadNotifications(); } } async function markNotifyRead(id) { const item = notifyList.find(n => n.id === id); if (item && !item.read) { item.read = true; setNotifyUnread(Math.max(0, notifyUnread - 1)); renderNotifyPanel(); } try { await fetch(`${API}/api/notifications/${id}/read`, { method: 'POST', headers: hdr() }); } catch {} } async function markAllNotifyRead() { try { const res = await fetch(`${API}/api/notifications/read-all`, { method: 'POST', headers: hdr() }); if (!res.ok) return; notifyList.forEach(n => { n.read = true; }); setNotifyUnread(0); renderNotifyPanel(); if (typeof onNotifyListChanged === 'function') onNotifyListChanged(); } catch {} } function showDesktopNotification(n) { if (!desktopNotify) return; if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return; try { const note = new Notification(n.title, { body: n.body || '', tag: 'whatido-' + n.id }); note.onclick = () => { window.focus(); if (n.link) location.href = n.link; note.close(); }; } catch {} } async function toggleDesktopNotifications() { if (desktopNotify) { desktopNotify = false; localStorage.setItem('notifyDesktop', '0'); renderNotifyPanel(); return; } if (typeof Notification === 'undefined') { showToast('Браузер не поддерживает уведомления'); return; } let perm = Notification.permission; if (perm === 'default') { try { perm = await Notification.requestPermission(); } catch {} } if (perm !== 'granted') { showToast('Разрешение на уведомления не выдано'); return; } desktopNotify = true; localStorage.setItem('notifyDesktop', '1'); renderNotifyPanel(); showToast('Уведомления в браузере включены'); } function handleIncomingNotification(n) { notifyList.unshift(n); if (notifyList.length > 40) notifyList.length = 40; setNotifyUnread(notifyUnread + 1); renderNotifyPanel(); if (n.level === 'warning' || n.level === 'critical') showToast(n.title); showDesktopNotification(n); if (typeof onNotificationArrived === 'function') onNotificationArrived(n); } function connectNotifyStream() { if (notifyStream) { notifyStream.close(); notifyStream = null; } const connect = () => { const t = sessionStorage.getItem('authToken'); if (!t) return; notifyStream = new EventSource(`${API}/api/notifications/stream?token=${encodeURIComponent(t)}`); notifyStream.addEventListener('ready', ev => { try { setNotifyUnread(JSON.parse(ev.data).unread); } catch {} }); notifyStream.addEventListener('notification', ev => { let n = null; try { n = JSON.parse(ev.data); } catch { return; } if (!n || !n.id) return; handleIncomingNotification(n); }); notifyStream.onerror = () => { if (notifyStream) { notifyStream.close(); notifyStream = null; } clearTimeout(notifyReconnectTimer); notifyReconnectTimer = setTimeout(connect, 5000); }; }; connect(); window.addEventListener('beforeunload', () => { if (notifyStream) notifyStream.close(); }); } function initNotifications() { const bell = document.getElementById('notifyBell'); const panel = notifyPanelEl(); if (!bell || !panel) return; bell.addEventListener('click', e => { e.preventDefault(); toggleNotifyPanel(); }); panel.addEventListener('click', e => { if (e.target.closest('#notifyReadAll')) { markAllNotifyRead(); return; } if (e.target.closest('#notifyDesktopToggle')) { toggleDesktopNotifications(); return; } const item = e.target.closest('[data-notify-id]'); if (!item) return; e.preventDefault(); const id = parseInt(item.dataset.notifyId, 10); const link = item.dataset.notifyLink; toggleNotifyPanel(false); markNotifyRead(id); if (link) location.href = link; }); document.addEventListener('click', e => { if (panel.hidden) return; if (e.target.closest('#notifyPanel') || e.target.closest('#notifyBell')) return; toggleNotifyPanel(false); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !panel.hidden) toggleNotifyPanel(false); }); loadNotifications(); connectNotifyStream(); } // --- Common UI --- function renderIcons() { if (window.lucide && typeof window.lucide.createIcons === 'function') { window.lucide.createIcons(); } } function showToast(msg) { const t = document.getElementById('toast'); if (!t) return; t.textContent = msg; t.classList.add('show'); setTimeout(() => t.classList.remove('show'), 3000); } function showImg(src) { const img = document.getElementById('imgModalSrc'); const m = document.getElementById('imgModal'); if (img && m) { img.src = src; m.classList.add('open'); } } async function fillAutocomplete() { const res = await fetch(`${API}/api/students`, { headers: hdr() }); const students = await res.json(); const names = students.map(s => s.name); const dlEdit = document.getElementById('studentEditList'); if (dlEdit) { dlEdit.innerHTML = ''; names.forEach(n => { const o = document.createElement('option'); o.value = n; dlEdit.appendChild(o); }); } const dlAdd = document.getElementById('studentAddList'); if (dlAdd) { dlAdd.innerHTML = ''; names.forEach(n => { const o = document.createElement('option'); o.value = n; dlAdd.appendChild(o); }); } } function copyStudentLink(name) { const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`; const done = () => showToast('Ссылка скопирована: ' + url); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(url).then(done).catch(() => fallbackCopy(url, done)); } else { fallbackCopy(url, done); } } function fallbackCopy(text, done) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); done(); } catch { prompt('Скопируйте вручную:', text); } document.body.removeChild(ta); } function studentUrlContent(name) { const url = `${location.origin}/?nameInput=${encodeURIComponent(name)}`; return `[InternetShortcut]\r\nURL=${url}\r\n`; } function safeWindowsName(name) { return String(name).replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_'); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } async function exportStudentUrlZip(students, zipName) { if (typeof JSZip === 'undefined') { alert('JSZip не загружен'); return; } const zip = new JSZip(); students.forEach(s => { zip.file(safeWindowsName(s.name) + '.url', studentUrlContent(s.name)); }); const blob = await zip.generateAsync({ type: 'blob' }); downloadBlob(blob, zipName); } function copyText(text) { const done = () => showToast('Скопировано: ' + text); if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done).catch(() => fallbackCopy(text, done)); } else { fallbackCopy(text, done); } } function renderPager(el, page, totalPages, onPage) { if (!el) return; if (totalPages <= 1) { el.innerHTML = ''; return; } let html = ``; for (let i = 1; i <= totalPages; i++) { if (totalPages > 9 && i > 2 && i < totalPages - 1 && Math.abs(i - page) > 2) { if (i === 3) html += ``; continue; } html += ``; } html += ``; el.innerHTML = html; el._onPage = onPage; el._page = page; if (!el._bound) { el._bound = true; el.addEventListener('click', e => { const btn = e.target.closest('button'); if (!btn || btn.disabled) return; const p = btn.dataset.page ? parseInt(btn.dataset.page, 10) : el._page + parseInt(btn.dataset.nav, 10); if (el._onPage) el._onPage(p); }); } } document.addEventListener('click', e => { const imgEl = e.target.closest('[data-img]'); if (imgEl) { e.preventDefault(); showImg(imgEl.dataset.img); return; } const out = e.target.closest('[data-action="logout"]'); if (out) { e.preventDefault(); doLogout(); } });