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 ``; } function renderChatThreads() { const list = document.getElementById('chatThreadList'); if (!list) return; if (!chatThreads.length) { list.innerHTML = '
Диалогов пока нет
'; return; } list.innerHTML = chatThreads.map(chatThreadCardHtml).join(''); } function chatFilesHtml(files) { if (!files || !files.length) return ''; return `
${files.map(f => { if (CHAT_IMAGE_EXT.test(f.name || '')) { return ` ${esc(f.name)} ${esc(f.name)} `; } return ` ${esc(f.name)} ${chatHumanSize(f.size)} `; }).join('')}
`; } 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 `
${esc(who)} ${role ? `${esc(role)}` : ''} ${esc(chatMessageTime(m.created_at))}
${m.body ? `
${esc(m.body)}
` : ''} ${chatFilesHtml(m.files)}
`; } 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 = '
Сообщений пока нет. Напишите первым.
'; 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) => ` ${esc(f.name)} ${chatHumanSize(f.size)} `).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 => ``).join('') || ''; } 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'); const imgModal = document.getElementById('imgModal'); 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') return; if (imgModal && imgModal.classList.contains('open')) { imgModal.classList.remove('open'); return; } if (newModal && newModal.classList.contains('open')) newModal.classList.remove('open'); }); if (imgModal) { imgModal.addEventListener('click', e => e.currentTarget.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(); })();