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
+87
View File
@@ -906,3 +906,90 @@ body.report-card-open .report-fields{display:flex}
.student-card__badge{font-size:12px;padding:6px 11px}
}
.chat-layout{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
.chat-layout.chat-layout-solo{grid-template-columns:1fr}
.chat-side{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;display:flex;flex-direction:column;max-height:calc(100dvh - 190px)}
.chat-side-head{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--border)}
.chat-side-title{font-size:.85rem;font-weight:600}
.chat-side-head .grow{flex:1}
.chat-new-btn{padding:7px 12px;font-size:.8rem}
.chat-new-btn svg{width:14px;height:14px}
.chat-thread-list{overflow-y:auto;display:flex;flex-direction:column}
.chat-side-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
.chat-thread{display:flex;gap:10px;align-items:flex-start;width:100%;text-align:left;background:transparent;border:none;border-bottom:1px solid var(--border);padding:11px 14px;cursor:pointer;font-family:inherit;transition:background .15s}
.chat-thread:last-child{border-bottom:none}
.chat-thread:hover{background:var(--bg)}
.chat-thread.active{background:rgba(37,99,235,.07)}
.chat-thread.unread .chat-thread-name{font-weight:700}
.chat-thread-avatar{width:34px;height:34px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:.9rem;flex-shrink:0}
.chat-thread-body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
.chat-thread-top{display:flex;align-items:center;gap:6px;min-width:0}
.chat-thread-top .grow{flex:1}
.chat-thread-name{font-size:.86rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-thread-time{font-size:.7rem;color:var(--muted);flex-shrink:0}
.chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-off{font-size:.65rem;color:var(--muted);border:1px solid var(--border);border-radius:5px;padding:1px 5px;flex-shrink:0}
.chat-thread-badge{min-width:18px;height:18px;padding:0 6px;border-radius:999px;background:#ef4444;color:#fff;font-size:.65rem;font-weight:700;line-height:18px;text-align:center;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;align-self:center}
.chat-pane{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);min-height:420px;display:flex;flex-direction:column;overflow:hidden}
.chat-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:70px 20px;text-align:center;color:var(--muted)}
.chat-empty svg{width:34px;height:34px;opacity:.4}
.chat-empty-title{font-size:.95rem;font-weight:600;color:var(--text)}
.chat-empty-sub{font-size:.82rem}
.chat-main{display:flex;flex-direction:column;min-height:520px}
.chat-empty[hidden],.chat-main[hidden],.chat-attach-list[hidden],.chat-new-btn[hidden]{display:none}
.chat-head{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--border)}
.chat-head .grow{flex:1}
.chat-avatar{width:36px;height:36px;border-radius:10px;background:rgba(37,99,235,.1);color:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0}
.chat-head-meta{display:flex;flex-direction:column;min-width:0}
.chat-head-name{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-head-sub{font-size:.75rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.chat-live{display:inline-flex;align-items:center;gap:6px;font-size:.72rem;color:var(--muted);flex-shrink:0}
.chat-live[hidden]{display:none}
.chat-live-dot{width:7px;height:7px;border-radius:50%;background:#22c55e}
.chat-live-dot{width:7px;height:7px;border-radius:50%;background:#22c55e}
.chat-messages{flex:1;min-height:260px;max-height:calc(100dvh - 400px);overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px;background:var(--bg)}
.chat-msgs-empty{margin:auto;color:var(--muted);font-size:.85rem;text-align:center}
.msg{display:flex;flex-direction:column;gap:5px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:10px 13px;max-width:78%;align-self:flex-start}
.msg-mine{align-self:flex-end;background:rgba(37,99,235,.07);border-color:rgba(37,99,235,.25)}
.msg-head{display:flex;align-items:center;gap:7px;font-size:.72rem;color:var(--muted)}
.msg-head .grow{flex:1}
.msg-who{font-weight:600;color:var(--text);font-size:.78rem}
.msg-role{border:1px solid var(--border);border-radius:5px;padding:0 5px;font-size:.65rem}
.msg-time{white-space:nowrap}
.msg-body{font-size:.88rem;line-height:1.45;white-space:pre-wrap;word-break:break-word}
.msg-files{display:flex;flex-wrap:wrap;gap:8px}
.msg-file{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:9px;padding:7px 10px;font-size:.78rem;color:var(--text);text-decoration:none;max-width:230px;min-width:0;transition:border-color .15s}
.msg-file:hover{border-color:var(--accent)}
.msg-file svg{width:16px;height:16px;flex-shrink:0;color:var(--muted)}
.msg-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1}
.msg-file-size{font-size:.68rem;color:var(--muted);flex-shrink:0}
.msg-file-img{flex-direction:column;align-items:stretch;gap:5px;padding:5px;max-width:180px}
.msg-file-img img{width:100%;height:110px;object-fit:cover;border-radius:6px;display:block;background:var(--bg)}
.msg-file-img .msg-file-name{font-size:.7rem;color:var(--muted)}
.chat-composer{border-top:1px solid var(--border);padding:11px 14px 13px;display:flex;flex-direction:column;gap:8px}
.chat-attach-list{display:flex;flex-wrap:wrap;gap:7px}
.chat-attach-list[hidden]{display:none}
.chat-attach-chip{display:inline-flex;align-items:center;gap:6px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:5px 9px;font-size:.75rem;max-width:250px}
.chat-attach-chip svg{width:13px;height:13px;color:var(--muted);flex-shrink:0}
.chat-attach-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.chat-attach-size{color:var(--muted);flex-shrink:0;font-size:.68rem}
.chat-attach-del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.8rem;line-height:1;padding:0 1px}
.chat-attach-del:hover{color:#ef4444}
.chat-compose-row{display:flex;align-items:flex-end;gap:8px}
.chat-attach-btn{width:40px;height:40px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--muted);cursor:pointer;flex-shrink:0}
.chat-attach-btn:hover{color:var(--accent);border-color:var(--accent)}
.chat-attach-btn svg{width:17px;height:17px}
.chat-input{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:10px;padding:10px 13px;font-size:.88rem;outline:none;font-family:inherit;resize:none;line-height:1.45;max-height:160px;min-height:40px}
.chat-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.12)}
.chat-composer .btn-primary{padding:11px 17px}
.chat-composer .btn-primary:disabled{opacity:.55;cursor:not-allowed}
.chat-composer .btn-primary svg{width:15px;height:15px}
.chat-hint{font-size:.75rem;margin:0}
.chat-new-modal{max-width:420px}
@media(max-width:900px){
.chat-layout{grid-template-columns:1fr}
.chat-side{max-height:230px}
.chat-messages{max-height:none}
.msg{max-width:92%}
}
+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 || '')}">
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Чаты — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="chat">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<div class="page-head">
<h2>Чаты</h2>
<p class="page-sub">Переписка тьюторов с администратором: сообщения, фотографии и документы</p>
<span class="page-status" id="authStatus"><span class="spinner dark"></span> Загрузка…</span>
<button class="head-btn" id="chatReloadBtn" type="button" title="Обновить диалоги"><i data-lucide="rotate-ccw"></i> Обновить</button>
</div>
<div class="chat-layout" id="chatLayout">
<div class="chat-side" id="chatSide">
<div class="chat-side-head">
<span class="chat-side-title">Диалоги</span>
<span class="grow"></span>
<button class="btn-primary ghost chat-new-btn" id="chatNewBtn" type="button" hidden title="Начать диалог с тьютором"><i data-lucide="user-plus"></i> Начать диалог</button>
</div>
<div class="chat-thread-list" id="chatThreadList"></div>
</div>
<div class="chat-pane" id="chatPane">
<div class="chat-empty" id="chatEmpty">
<i data-lucide="messages-square"></i>
<p class="chat-empty-title">Диалог не выбран</p>
<p class="chat-empty-sub">Выберите тьютора слева или начните новый диалог</p>
</div>
<div class="chat-main" id="chatMain" hidden>
<div class="chat-head">
<span class="chat-avatar" id="chatAvatar"></span>
<div class="chat-head-meta">
<div class="chat-head-name" id="chatPeerName"></div>
<div class="chat-head-sub" id="chatPeerSub"></div>
</div>
<span class="grow"></span>
</div>
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-composer">
<div class="chat-attach-list" id="chatAttachList" hidden></div>
<div class="chat-compose-row">
<button class="chat-attach-btn" id="chatAttachBtn" type="button" title="Прикрепить файл или фото"><i data-lucide="paperclip"></i></button>
<input type="file" id="chatFileInput" multiple hidden>
<textarea class="chat-input" id="chatInput" rows="1" placeholder="Сообщение… (Ctrl+V — вставить фото)" maxlength="5000"></textarea>
<button class="btn-primary" id="chatSendBtn" type="button"><i data-lucide="send"></i> Отправить</button>
</div>
<div class="hint chat-hint" id="chatHint">Можно вставить скриншот или фото прямо из буфера обмена — Ctrl+V. Фото, документы и архивы, до 10 штук.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-overlay" id="chatNewModal">
<div class="edit-modal chat-new-modal">
<h3>Начать диалог</h3>
<div class="settings-field">
<label>Тьютор</label>
<select id="chatNewUser" class="settings-input"></select>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="chatNewCancel">Отмена</button>
<button type="submit" class="btn-primary" id="chatNewOk">Открыть диалог</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
<script src="js/chat.js"></script>
</body>
</html>
+529
View File
@@ -0,0 +1,529 @@
const CHAT_PAGE_SIZE = 100;
const CHAT_MAX_FILES = 10;
const CHAT_MAX_FILE_MB = 50;
const CHAT_ALLOWED_EXT = new Set([
'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.avif', '.heic', '.heif', '.jfif',
'.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt', '.ods',
'.xls', '.xlsx', '.csv', '.ppt', '.pptx',
'.zip', '.rar', '.7z', '.tar', '.gz',
]);
const CHAT_IMAGE_EXT = /\.(jpe?g|jfif|png|gif|webp|bmp|avif|ico)$/i;
const CHAT_VIDEO_EXT = /\.(?:mp4|m4v|webm|ogv)$/i;
let chatThreads = [];
let chatActiveId = 0;
let chatMessages = [];
let chatPendingFiles = [];
let chatLoading = false;
let chatSending = false;
function chatHumanSize(b) {
const n = Number(b) || 0;
if (n < 1024) return `${n} Б`;
if (n < 1048576) return `${(n / 1024).toFixed(1)} КБ`;
return `${(n / 1048576).toFixed(1)} МБ`;
}
function chatExt(name) {
const m = /\.[^.\\/]+$/.exec(String(name || ''));
return m ? m[0].toLowerCase() : '';
}
function chatFileIcon(name) {
if (CHAT_IMAGE_EXT.test(name || '')) return 'image';
if (CHAT_VIDEO_EXT.test(name || '')) return 'video';
if (/\.(zip|rar|7z)$/i.test(name || '')) return 'archive';
if (/\.pdf$/i.test(name || '')) return 'file-text';
if (/\.(xlsx?|csv)$/i.test(name || '')) return 'sheet';
if (/\.(docx?|rtf|txt|md)$/i.test(name || '')) return 'file-type';
return 'paperclip';
}
function chatFileUrl(f, play) {
const base = `${API}/api/chat/files/${f.token}?token=${encodeURIComponent(token)}`;
if (play && CHAT_VIDEO_EXT.test(f.name || '')) return `${base}&play=1`;
return base;
}
function chatFileThumbUrl(f) {
return `${chatFileUrl(f)}&thumb=1`;
}
function chatThreadTime(ts) {
if (!ts) return '';
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 fmtTimeOnly(d);
return fmtDateShort(d);
}
function chatThreadCardHtml(t) {
const active = t.id === chatActiveId ? ' active' : '';
const unread = Number(t.unread) || 0;
const preview = (t.last_message_text || '').trim() || 'Нет сообщений';
return `<button type="button" class="chat-thread${active}${unread ? ' unread' : ''}" data-thread="${t.id}">
<span class="chat-thread-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
<span class="chat-thread-body">
<span class="chat-thread-top">
<span class="chat-thread-name">${esc(t.tutor_name || t.tutor_username)}</span>
${t.tutor_active === false ? '<span class="chat-off" title="Пользователь отключён">отключён</span>' : ''}
<span class="grow"></span>
<span class="chat-thread-time">${esc(chatThreadTime(t.last_message_at || t.created_at))}</span>
</span>
<span class="chat-thread-preview">${esc(preview)}</span>
</span>
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
</button>`;
}
function renderChatThreads() {
const list = document.getElementById('chatThreadList');
if (!list) return;
if (!chatThreads.length) {
list.innerHTML = '<div class="chat-side-empty">Диалогов пока нет</div>';
return;
}
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
}
function chatFilesHtml(files) {
if (!files || !files.length) return '';
return `<div class="msg-files">${files.map(f => {
if (CHAT_IMAGE_EXT.test(f.name || '')) {
return `<a class="msg-file msg-file-img" href="${chatFileUrl(f)}" target="_blank" rel="noopener">
<img src="${chatFileThumbUrl(f)}" alt="${esc(f.name)}" loading="lazy">
<span class="msg-file-name">${esc(f.name)}</span>
</a>`;
}
return `<a class="msg-file" href="${chatFileUrl(f, true)}" target="_blank" rel="noopener">
<i data-lucide="${chatFileIcon(f.name)}"></i>
<span class="msg-file-name">${esc(f.name)}</span>
<span class="msg-file-size">${chatHumanSize(f.size)}</span>
</a>`;
}).join('')}</div>`;
}
function chatMessageHtml(m) {
const mine = Number(m.sender_id) === Number(currentUser?.id);
const who = mine ? 'Вы' : (m.sender_name || 'Тьютор');
const role = mine ? '' : (m.sender_role === 'admin' ? 'администратор' : 'тьютор');
return `<div class="msg ${mine ? 'msg-mine' : ''}" data-msg="${m.id}">
<div class="msg-head">
<span class="msg-who">${esc(who)}</span>
${role ? `<span class="msg-role">${esc(role)}</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
</div>
${m.body ? `<div class="msg-body">${esc(m.body)}</div>` : ''}
${chatFilesHtml(m.files)}
</div>`;
}
function addChatMessage(m) {
if (!m || m.id === undefined || m.id === null) return false;
if (chatMessages.some(x => Number(x.id) === Number(m.id))) return false;
chatMessages.push(m);
return true;
}
function renderChatMessages() {
const box = document.getElementById('chatMessages');
if (!box) return;
if (!chatMessages.length) {
box.innerHTML = '<div class="chat-msgs-empty">Сообщений пока нет. Напишите первым.</div>';
return;
}
box.innerHTML = chatMessages.map(chatMessageHtml).join('');
renderIcons();
}
function scrollChatDown() {
const box = document.getElementById('chatMessages');
if (box) box.scrollTop = box.scrollHeight;
}
function chatAtBottom() {
const box = document.getElementById('chatMessages');
if (!box) return true;
return box.scrollHeight - box.scrollTop - box.clientHeight < 120;
}
function chatMessageTime(ts) {
const d = new Date(ts);
if (Number.isNaN(d.getTime())) return '';
return `${fmtDateShort(d)} ${fmtTimeOnly(d)}`;
}
async function chatApi(path, options) {
const res = await fetch(`${API}${path}`, options);
let data = null;
try { data = await res.json(); } catch {}
if (!res.ok) throw new Error((data && data.error) || `Ошибка ${res.status}`);
return data;
}
async function loadChatThreads() {
try {
const data = await chatApi('/api/chat/threads', { headers: hdr() });
chatThreads = data.items || [];
setChatUnread(data.unread);
renderChatThreads();
} catch {
showToast('Не удалось загрузить диалоги', true);
}
}
function renderChatHeader() {
const thread = chatThreads.find(t => t.id === chatActiveId);
const empty = document.getElementById('chatEmpty');
const main = document.getElementById('chatMain');
if (!thread) {
if (empty) empty.hidden = false;
if (main) main.hidden = true;
return;
}
if (empty) empty.hidden = true;
if (main) main.hidden = false;
const av = document.getElementById('chatAvatar');
const nm = document.getElementById('chatPeerName');
const sub = document.getElementById('chatPeerSub');
if (av) av.textContent = (thread.tutor_name || '?').slice(0, 1).toUpperCase();
if (nm) nm.textContent = isAdmin() ? (thread.tutor_name || thread.tutor_username) : 'Администратор';
if (sub) {
sub.textContent = isAdmin()
? `@${thread.tutor_username}${thread.tutor_active === false ? ' · отключён' : ''}`
: 'Ответ администратора';
}
}
async function loadChatMessages() {
if (!chatActiveId) {
renderChatHeader();
renderChatMessages();
return;
}
if (chatLoading) return;
chatLoading = true;
try {
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages?limit=${CHAT_PAGE_SIZE}`, { headers: hdr() });
chatMessages = data.items || [];
if (data.thread) {
const idx = chatThreads.findIndex(t => t.id === data.thread.id);
if (idx >= 0) chatThreads[idx] = data.thread;
}
renderChatHeader();
renderChatThreads();
renderChatMessages();
scrollChatDown();
await markChatRead();
} catch (e) {
showToast(e.message || 'Не удалось загрузить сообщения', true);
} finally {
chatLoading = false;
}
}
async function markChatRead() {
if (!chatActiveId) return;
try {
const data = await chatApi(`/api/chat/threads/${chatActiveId}/read`, { method: 'POST', headers: hdr() });
setChatUnread(data.unread);
const thread = chatThreads.find(t => t.id === chatActiveId);
if (thread) thread.unread = 0;
renderChatThreads();
} catch {}
}
function chatPasteFileName(file) {
const base = String(file.name || '').trim();
const stem = base.replace(/\.[a-z0-9]{1,8}$/i, '');
if (base && !/^(image|photo|picture|blob|file|screenshot|снимок|скриншот|изображение|файл)$/i.test(stem)) return base;
const pad = n => String(n).padStart(2, '0');
const d = new Date();
return `Вставка ${todayIso()} ${nowHm().replace(':', '-')}-${pad(d.getSeconds())}${chatExt(file.name) || '.png'}`;
}
function renderChatAttach() {
const box = document.getElementById('chatAttachList');
if (!box) return;
if (!chatPendingFiles.length) {
box.hidden = true;
box.innerHTML = '';
return;
}
box.hidden = false;
box.innerHTML = chatPendingFiles.map((f, i) => `<span class="chat-attach-chip">
<i data-lucide="${chatFileIcon(f.name)}"></i>
<span class="chat-attach-name" title="${esc(f.name)}">${esc(f.name)}</span>
<span class="chat-attach-size">${chatHumanSize(f.size)}</span>
<button type="button" class="chat-attach-del" data-remove="${i}" title="Убрать">✕</button>
</span>`).join('');
renderIcons();
}
function addChatFiles(list) {
const skipped = [];
let added = 0;
for (const file of list) {
if (chatPendingFiles.length >= CHAT_MAX_FILES) {
skipped.push(`Не больше ${CHAT_MAX_FILES} файлов`);
break;
}
if (file.size > CHAT_MAX_FILE_MB * 1024 * 1024) {
skipped.push(`«${file.name}» больше ${CHAT_MAX_FILE_MB} МБ`);
continue;
}
if (!CHAT_ALLOWED_EXT.has(chatExt(file.name))) {
skipped.push(`«${file.name}»: недопустимый тип файла`);
continue;
}
chatPendingFiles.push(file);
added++;
}
if (added) renderChatAttach();
if (skipped.length) showToast(skipped[0], true);
return added;
}
function removeChatFile(i) {
chatPendingFiles.splice(i, 1);
renderChatAttach();
}
function autoGrowChatInput() {
const input = document.getElementById('chatInput');
if (!input) return;
input.style.height = 'auto';
input.style.height = `${Math.min(input.scrollHeight, 160)}px`;
}
function selectChatThread(id) {
chatActiveId = Number(id) || 0;
renderChatThreads();
loadChatMessages();
}
function onChatMessage(p) {
if (!p || !p.message) return;
const threadId = Number(p.thread_id);
const idx = chatThreads.findIndex(t => t.id === threadId);
const m = p.message;
if (idx < 0) {
loadChatThreads();
} else {
chatThreads[idx].last_message_text = m.body || 'Вложение';
chatThreads[idx].last_message_at = m.created_at;
renderChatThreads();
}
if (threadId !== chatActiveId) return;
const stick = chatAtBottom();
addChatMessage(m);
renderChatMessages();
if (stick) scrollChatDown();
markChatRead();
}
async function openChatNewModal() {
const modal = document.getElementById('chatNewModal');
const select = document.getElementById('chatNewUser');
if (!modal || !select) return;
try {
const users = await chatApi('/api/users', { headers: hdr() });
const tutors = (users || []).filter(u => u.role !== 'admin' && u.is_active !== false);
const known = new Set(chatThreads.map(t => t.tutor_id));
const rest = tutors.filter(u => !known.has(u.id));
const pool = rest.length ? rest : tutors;
select.innerHTML = pool.map(u => `<option value="${u.id}">${esc(u.name || u.username)} (@${esc(u.username)})</option>`).join('')
|| '<option value="">Нет доступных тьюторов</option>';
} catch (e) {
showToast(e.message || 'Не удалось загрузить пользователей', true);
return;
}
modal.classList.add('open');
}
async function sendChatMessage() {
if (chatSending || !chatActiveId) return;
const input = document.getElementById('chatInput');
const body = String(input.value || '').trim();
const files = chatPendingFiles.slice();
if (!body && !files.length) return;
chatSending = true;
const sendBtn = document.getElementById('chatSendBtn');
if (sendBtn) sendBtn.disabled = true;
try {
const fd = new FormData();
fd.append('body', body);
for (const f of files) fd.append('files', f, f.name);
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages`, { method: 'POST', headers: hdr(), body: fd });
chatPendingFiles = [];
renderChatAttach();
input.value = '';
autoGrowChatInput();
const stick = chatAtBottom();
if (data.message) addChatMessage(data.message);
const thread = chatThreads.find(t => t.id === chatActiveId);
if (thread) {
thread.last_message_text = (data.message && data.message.body) || `Вложение: ${files[0] ? files[0].name : ''}`;
thread.last_message_at = data.message ? data.message.created_at : thread.last_message_at;
}
renderChatThreads();
renderChatMessages();
if (stick) scrollChatDown();
} catch (e) {
showToast(e.message || 'Сообщение не отправлено', true);
} finally {
chatSending = false;
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
}
async function submitChatNew() {
const select = document.getElementById('chatNewUser');
const modal = document.getElementById('chatNewModal');
const userId = Number(select.value);
if (!userId) {
showToast('Выберите тьютора', true);
return;
}
try {
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: JSON.stringify({ user_id: userId }) });
if (modal) modal.classList.remove('open');
if (data.thread) {
if (!chatThreads.some(t => t.id === data.thread.id)) chatThreads.unshift(data.thread);
renderChatThreads();
selectChatThread(data.thread.id);
}
} catch (e) {
showToast(e.message || 'Не удалось создать диалог', true);
}
}
async function ensureOwnThread() {
try {
const data = await chatApi('/api/chat/threads', { method: 'POST', headers: hdrJson(), body: '{}' });
if (data.thread) {
if (!chatThreads.some(t => t.id === data.thread.id)) {
chatThreads.unshift(data.thread);
renderChatThreads();
}
return data.thread.id;
}
} catch {}
return 0;
}
function initChatPage() {
const list = document.getElementById('chatThreadList');
const input = document.getElementById('chatInput');
const fileInput = document.getElementById('chatFileInput');
const attachBtn = document.getElementById('chatAttachBtn');
const sendBtn = document.getElementById('chatSendBtn');
const reloadBtn = document.getElementById('chatReloadBtn');
const newBtn = document.getElementById('chatNewBtn');
const newModal = document.getElementById('chatNewModal');
const newCancel = document.getElementById('chatNewCancel');
const newOk = document.getElementById('chatNewOk');
const attachBox = document.getElementById('chatAttachList');
if (list) {
list.addEventListener('click', e => {
const btn = e.target.closest('[data-thread]');
if (!btn) return;
selectChatThread(Number(btn.dataset.thread));
});
}
if (attachBox) {
attachBox.addEventListener('click', e => {
const btn = e.target.closest('[data-remove]');
if (btn) removeChatFile(Number(btn.dataset.remove));
});
}
if (sendBtn) sendBtn.addEventListener('click', sendChatMessage);
if (reloadBtn) reloadBtn.addEventListener('click', () => { loadChatThreads(); loadChatMessages(); });
if (attachBtn) attachBtn.addEventListener('click', () => fileInput && fileInput.click());
if (fileInput) {
fileInput.addEventListener('change', () => {
addChatFiles([...fileInput.files]);
fileInput.value = '';
});
}
if (input) {
input.addEventListener('input', autoGrowChatInput);
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendChatMessage();
}
});
}
if (newBtn) newBtn.addEventListener('click', openChatNewModal);
if (newCancel) newCancel.addEventListener('click', () => newModal && newModal.classList.remove('open'));
if (newModal) {
newModal.addEventListener('click', e => { if (e.target === newModal) newModal.classList.remove('open'); });
}
if (newOk) newOk.addEventListener('click', submitChatNew);
document.addEventListener('paste', e => {
if (!chatActiveId) return;
const active = document.activeElement;
if (active && active !== input && active !== document.body && !(attachBox && attachBox.contains(active))) return;
const clip = e.clipboardData;
if (!clip) return;
const pasted = [];
for (const item of Array.from(clip.items || [])) {
if (item.kind !== 'file') continue;
const file = item.getAsFile();
if (file) pasted.push(file);
}
if (!pasted.length && clip.files && clip.files.length) pasted.push(...Array.from(clip.files));
if (!pasted.length) return;
e.preventDefault();
const normalized = pasted.map(raw => {
const name = chatPasteFileName(raw);
if (raw.name === name) return raw;
return new File([raw], name, { type: raw.type || 'application/octet-stream', lastModified: raw.lastModified });
});
if (addChatFiles(normalized)) showToast(normalized.length === 1 ? 'Файл прикреплён' : `Файлов прикреплено: ${normalized.length}`);
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && newModal && newModal.classList.contains('open')) newModal.classList.remove('open');
});
if (newBtn) newBtn.hidden = !isAdmin();
const side = document.getElementById('chatSide');
const layout = document.getElementById('chatLayout');
if (!isAdmin() && side) side.hidden = true;
if (!isAdmin() && layout) layout.classList.add('chat-layout-solo');
const emptySub = document.querySelector('#chatEmpty .chat-empty-sub');
if (emptySub && !isAdmin()) emptySub.textContent = 'Напишите администратору — сообщения доставятся мгновенно';
loadChatThreads().then(async () => {
if (!isAdmin()) {
const id = chatThreads[0] ? chatThreads[0].id : await ensureOwnThread();
if (id) selectChatThread(id);
else renderChatHeader();
} else if (chatThreads[0]) {
selectChatThread(chatThreads[0].id);
} else {
renderChatHeader();
renderChatMessages();
}
});
}
(async function () {
if (!(await checkAuth())) return;
buildSidebar(document.body.dataset.page);
const status = document.getElementById('authStatus');
if (status) {
status.textContent = '';
status.classList.add('loaded');
}
initChatPage();
})();