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 ? `` : '';
return `
${n.label}
${badgeHtml}
`;
};
el.innerHTML = `
`;
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('') : '
Пока уведомлений нет
'}
`;
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(); }
});