feat(chat): личный чат тьютор ↔ админ с вложениями

Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat
вместо общей ленты notifications, вложения с белым списком расширений
и вставкой из буфера (Ctrl+V), очистка по chat_retention_days.
Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
dev
2026-10-05 17:30:26 +03:00
parent 3836fc82ef
commit 235fd43af4
12 changed files with 1409 additions and 10 deletions
+56
View File
@@ -52,6 +52,7 @@ function buildSidebar(active) {
{ page: 'journal', label: 'Журнал', icon: 'book-open' },
{ page: 'lessons', label: 'Отчёты о занятиях', icon: 'notebook-pen' },
{ page: 'notifications', label: 'Уведомления', icon: 'bell', badge: true },
{ page: 'chat', label: 'Чаты', icon: 'messages-square', chatBadge: true },
{ page: 'students', label: 'Ученики', icon: 'graduation-cap' },
{ page: 'groups', label: 'Группы', icon: 'users' },
{ page: 'photos', label: 'Фото', icon: 'Image' },
@@ -74,10 +75,12 @@ function buildSidebar(active) {
const renderItem = n => {
const isAct = n.page === active;
const badgeHtml = n.badge ? `<span class="sidebar-badge" id="sidebarNotifyBadge" ${notifyUnread > 0 ? '' : 'hidden'}>${notifyUnread > 99 ? '99+' : notifyUnread}</span>` : '';
const chatBadgeHtml = n.chatBadge ? `<span class="sidebar-badge" id="sidebarChatBadge" ${chatUnread > 0 ? '' : 'hidden'}>${chatUnread > 99 ? '99+' : chatUnread}</span>` : '';
return `<a href="${n.page}.html" data-nav="${n.page}" class="${isAct ? 'active' : ''}" title="${n.label}">
<i data-lucide="${n.icon}"></i>
<span class="nav-text">${n.label}</span>
${badgeHtml}
${chatBadgeHtml}
</a>`;
};
el.innerHTML = `
@@ -133,6 +136,8 @@ function buildSidebar(active) {
loadVersion();
loadBrand();
initNotifications();
loadChatUnread();
connectChatStream();
}
async function loadBrand() {
@@ -218,6 +223,57 @@ function setNotifyUnread(n) {
}
}
let chatUnread = 0;
let chatStream = null;
let chatReconnectTimer = null;
function setChatUnread(n) {
chatUnread = Number(n) || 0;
const badge = document.getElementById('sidebarChatBadge');
if (badge) {
badge.textContent = chatUnread > 99 ? '99+' : String(chatUnread);
badge.hidden = chatUnread <= 0;
}
}
async function loadChatUnread() {
try {
const res = await fetch(`${API}/api/chat/unread`, { headers: hdr() });
if (!res.ok) return;
setChatUnread((await res.json()).unread);
} catch {}
}
function connectChatStream() {
if (chatStream) { chatStream.close(); chatStream = null; }
const connect = () => {
const t = sessionStorage.getItem('authToken');
if (!t) return;
chatStream = new EventSource(`${API}/api/chat/stream?token=${encodeURIComponent(t)}`);
chatStream.addEventListener('ready', ev => {
try { setChatUnread(JSON.parse(ev.data).unread); } catch {}
});
chatStream.addEventListener('message', ev => {
let p = null;
try { p = JSON.parse(ev.data); } catch { return; }
if (!p || !p.message) return;
const m = p.message;
if (m.sender_id !== currentUser?.id) {
setChatUnread(chatUnread + 1);
showToast(`Новое сообщение в чате: ${(m.sender_name || '').trim() || 'диалог'}`);
}
if (typeof onChatMessage === 'function') onChatMessage(p);
});
chatStream.onerror = () => {
if (chatStream) { chatStream.close(); chatStream = null; }
clearTimeout(chatReconnectTimer);
chatReconnectTimer = setTimeout(connect, 5000);
};
};
connect();
window.addEventListener('beforeunload', () => { if (chatStream) chatStream.close(); });
}
function notifyItemHtml(n, expanded) {
const level = NOTIFY_LEVELS[n.level] ? ` level-${n.level}` : '';
return `<a href="#" class="notify-item${n.read ? '' : ' unread'}${level}" data-notify-id="${n.id}" data-notify-link="${attrEsc(n.link || '')}">