Files
WhatIDo/public/js/chat.js
T
dev 62239a35a4 fix(chat): валидная разметка карточки диалога и доступ с клавиатуры
Карточка треда была вложенными <button>, поэтому кнопки действий внутри
неё ломали HTML-структуру. Заменена на <div role="button" tabindex="0">
с обработкой Enter/Space; классы действий переименованы в
chat-thread-act / chat-thread-act-danger, чтобы не путаться с .chat-act.

Также ужата шапка списка диалогов: заголовок не сжимается, кнопки
не переносятся, подпись у кнопки скрытых заменена на бейдж.
2026-10-05 20:02:03 +03:00

824 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
let chatEditId = 0;
let chatHiddenMode = false;
let chatHiddenCount = 0;
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() || 'Нет сообщений';
const hidden = t.deleted_at ? ' is-hidden' : '';
const acts = isAdmin() ? (t.deleted_at
? `<span class="chat-thread-acts">
<button type="button" class="chat-thread-act" data-thread-restore="${t.id}" title="Вернуть диалог в список"><i data-lucide="archive-restore"></i></button>
<button type="button" class="chat-thread-act chat-thread-act-danger" data-thread-purge="${t.id}" title="Удалить диалог безвозвратно"><i data-lucide="trash-2"></i></button>
</span>`
: `<span class="chat-thread-acts">
<button type="button" class="chat-thread-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
</span>`) : '';
return `<div class="chat-thread${active}${hidden}${unread ? ' unread' : ''}" data-thread="${t.id}" role="button" tabindex="0">
<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>' : ''}
${t.deleted_at ? '<span class="chat-off chat-hidden-tag" 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>` : ''}
${acts}
</div>`;
}
function renderChatThreads() {
const list = document.getElementById('chatThreadList');
if (!list) return;
if (!chatThreads.length) {
list.innerHTML = `<div class="chat-side-empty">${chatHiddenMode ? 'Скрытых диалогов нет' : 'Диалогов пока нет'}</div>`;
return;
}
list.innerHTML = chatThreads.map(chatThreadCardHtml).join('');
renderIcons();
}
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)}" data-img="${esc(chatFileUrl(f))}" title="Открыть">
<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' ? 'администратор' : 'тьютор');
if (m.deleted_at) {
return `<div class="msg msg-deleted ${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>
<div class="msg-body msg-deleted-body">Сообщение удалено</div>
</div>`;
}
const canDelete = mine || isAdmin();
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>` : ''}
${m.edited_at ? `<span class="msg-edited" title="Изменено ${esc(chatMessageTime(m.edited_at))}">изменено</span>` : ''}
<span class="grow"></span>
<span class="msg-time">${esc(chatMessageTime(m.created_at))}</span>
${canDelete ? `<span class="msg-actions">
${mine ? `<button type="button" class="msg-act" data-msg-edit="${m.id}" title="Редактировать"><i data-lucide="pencil"></i></button>` : ''}
<button type="button" class="msg-act msg-act-del" data-msg-del="${m.id}" title="Удалить"><i data-lucide="trash-2"></i></button>
</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${chatHiddenMode ? '?deleted=1' : ''}`, { headers: hdr() });
chatThreads = data.items || [];
chatHiddenCount = Number(data.hidden) || 0;
setChatUnread(data.unread);
renderChatHiddenBtn();
renderChatThreads();
} catch {
showToast('Не удалось загрузить диалоги', true);
}
}
function renderChatHiddenBtn() {
const btn = document.getElementById('chatHiddenBtn');
const hint = document.getElementById('chatHiddenHint');
const count = document.getElementById('chatHiddenCount');
if (!btn) return;
btn.hidden = !isAdmin();
btn.classList.toggle('on', chatHiddenMode);
if (hint) hint.hidden = !chatHiddenMode;
if (count) {
count.hidden = !chatHiddenMode || !chatHiddenCount;
count.textContent = chatHiddenCount > 99 ? '99+' : String(chatHiddenCount);
}
btn.title = chatHiddenMode ? 'Показать активные диалоги' : 'Показать скрытые диалоги';
}
function resetChatHiddenMode() {
if (!chatHiddenMode) return;
chatHiddenMode = false;
chatActiveId = 0;
renderChatHiddenBtn();
}
function afterChatThreadListChanged() {
const stillThere = chatThreads.some(t => t.id === chatActiveId);
if (!stillThere) chatActiveId = 0;
renderChatThreads();
if (chatActiveId) loadChatMessages();
else {
renderChatHeader();
renderChatMessages();
}
}
async function hideChatThread(id) {
const thread = chatThreads.find(t => t.id === Number(id));
if (!thread) return;
const name = thread.tutor_name || thread.tutor_username || 'диалог';
if (!confirm(`Скрыть диалог с «${name}»? Он уйдёт из списка активных, сообщения сохранятся и будут удалены автоматически через срок из настроек корзины.`)) return;
try {
await chatApi(`/api/chat/threads/${id}`, { method: 'DELETE', headers: hdr() });
chatHiddenCount += 1;
showToast('Диалог скрыт');
if (chatActiveId === Number(id)) chatActiveId = 0;
chatThreads = chatThreads.filter(t => t.id !== Number(id));
renderChatHiddenBtn();
afterChatThreadListChanged();
loadChatUnread();
} catch (e) {
showToast(e.message || 'Не удалось скрыть диалог', true);
}
}
async function restoreChatThread(id) {
try {
await chatApi(`/api/chat/threads/${id}/restore`, { method: 'PUT', headers: hdr() });
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
showToast('Диалог восстановлен');
if (chatActiveId === Number(id)) chatActiveId = 0;
chatThreads = chatThreads.filter(t => t.id !== Number(id));
renderChatHiddenBtn();
afterChatThreadListChanged();
loadChatUnread();
} catch (e) {
showToast(e.message || 'Не удалось восстановить диалог', true);
}
}
async function purgeChatThread(id) {
const thread = chatThreads.find(t => t.id === Number(id));
if (!thread) return;
const name = thread.tutor_name || thread.tutor_username || 'диалог';
if (!confirm(`Удалить диалог с «${name}» безвозвратно? Сообщения и вложения будут удалены после срока из настроек корзины.`)) return;
try {
await chatApi(`/api/chat/threads/${id}/permanent`, { method: 'DELETE', headers: hdr() });
chatThreads = chatThreads.filter(t => t.id !== Number(id));
if (chatActiveId === Number(id)) chatActiveId = 0;
afterChatThreadListChanged();
showToast('Удаление запланировано');
} catch (e) {
showToast(e.message || 'Не удалось запланировать удаление', 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(el) {
const input = el || 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 if (chatHiddenMode) {
renderChatThreads();
} 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();
}
function applyChatThreadPreview(p) {
if (!p || !p.preview) return;
const idx = chatThreads.findIndex(t => t.id === Number(p.thread_id));
if (idx < 0) return;
chatThreads[idx].last_message_text = p.preview.last_message_text || '';
chatThreads[idx].last_message_at = p.preview.last_message_at || chatThreads[idx].last_message_at;
renderChatThreads();
}
function onChatMessageUpdate(p) {
if (!p || !p.message) return;
applyChatThreadPreview(p);
if (Number(p.thread_id) !== chatActiveId) return;
const m = p.message;
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
if (idx < 0) {
addChatMessage(m);
} else {
chatMessages[idx] = { ...chatMessages[idx], ...m };
}
renderChatMessages();
}
function onChatMessageDelete(p) {
if (!p || !p.message) return;
applyChatThreadPreview(p);
if (Number(p.thread_id) !== chatActiveId) return;
const m = p.message;
const idx = chatMessages.findIndex(x => Number(x.id) === Number(m.id));
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...m, body: '', files: [], edited_at: null };
renderChatMessages();
if (Number(m.sender_id) !== Number(currentUser?.id)) markChatRead();
}
function onChatThreadUpdate(p) {
if (!p || !isAdmin()) return;
if (p.deleted_at) {
if (chatHiddenMode) return;
chatHiddenCount += 1;
} else {
if (!chatHiddenMode) return;
chatHiddenCount = Math.max(0, chatHiddenCount - 1);
}
loadChatThreads();
}
function openChatEditModal(messageId) {
const modal = document.getElementById('chatEditModal');
const input = document.getElementById('chatEditText');
if (!modal || !input) return;
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
if (!msg || msg.deleted_at) return;
chatEditId = Number(messageId);
input.value = String(msg.body || '');
modal.classList.add('open');
input.focus();
input.setSelectionRange(input.value.length, input.value.length);
autoGrowChatInput(input);
}
function closeChatEditModal() {
const modal = document.getElementById('chatEditModal');
if (modal) modal.classList.remove('open');
chatEditId = 0;
}
async function submitChatEdit() {
const input = document.getElementById('chatEditText');
const saveBtn = document.getElementById('chatEditSave');
if (!input || !chatEditId) return;
const body = String(input.value || '').trim();
if (!body) {
showToast('Сообщение не может быть пустым', true);
return;
}
if (saveBtn) saveBtn.disabled = true;
try {
const data = await chatApi(`/api/chat/threads/${chatActiveId}/messages/${chatEditId}`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify({ body }),
});
if (data.message) {
const idx = chatMessages.findIndex(m => Number(m.id) === Number(data.message.id));
if (idx >= 0) chatMessages[idx] = { ...chatMessages[idx], ...data.message };
renderChatMessages();
}
closeChatEditModal();
} catch (e) {
showToast(e.message || 'Не удалось изменить сообщение', true);
} finally {
if (saveBtn) saveBtn.disabled = false;
}
}
async function deleteChatMessage(messageId) {
const msg = chatMessages.find(m => Number(m.id) === Number(messageId));
if (!msg) return;
const mine = Number(msg.sender_id) === Number(currentUser?.id);
if (!confirm(mine
? 'Удалить сообщение? Вложения будут удалены безвозвратно.'
: 'Удалить сообщение собеседника? Вложения будут удалены безвозвратно.')) return;
try {
await chatApi(`/api/chat/threads/${chatActiveId}/messages/${messageId}`, { method: 'DELETE', headers: hdr() });
const idx = chatMessages.findIndex(m => Number(m.id) === Number(messageId));
if (idx >= 0) {
chatMessages[idx] = { ...chatMessages[idx], body: '', files: [], edited_at: null, deleted_at: new Date().toISOString() };
}
renderChatMessages();
loadChatThreads();
} catch (e) {
showToast(e.message || 'Не удалось удалить сообщение', true);
}
}
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 hiddenBtn = document.getElementById('chatHiddenBtn');
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');
const box = document.getElementById('chatMessages');
const editModal = document.getElementById('chatEditModal');
const editInput = document.getElementById('chatEditText');
const editCancel = document.getElementById('chatEditCancel');
const editSave = document.getElementById('chatEditSave');
if (box) {
box.addEventListener('click', e => {
const editBtn = e.target.closest('[data-msg-edit]');
if (editBtn) {
openChatEditModal(Number(editBtn.dataset.msgEdit));
return;
}
const delBtn = e.target.closest('[data-msg-del]');
if (delBtn) deleteChatMessage(Number(delBtn.dataset.msgDel));
});
}
if (editCancel) editCancel.addEventListener('click', closeChatEditModal);
if (editSave) editSave.addEventListener('click', submitChatEdit);
if (editModal) {
editModal.addEventListener('click', e => { if (e.target === editModal) closeChatEditModal(); });
}
if (editInput) {
editInput.addEventListener('input', () => autoGrowChatInput(editInput));
editInput.addEventListener('keydown', e => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
submitChatEdit();
}
});
}
if (list) {
list.addEventListener('click', e => {
const hideBtn = e.target.closest('[data-thread-hide]');
if (hideBtn) { e.stopPropagation(); hideChatThread(Number(hideBtn.dataset.threadHide)); return; }
const restoreBtn = e.target.closest('[data-thread-restore]');
if (restoreBtn) { e.stopPropagation(); restoreChatThread(Number(restoreBtn.dataset.threadRestore)); return; }
const purgeBtn = e.target.closest('[data-thread-purge]');
if (purgeBtn) { e.stopPropagation(); purgeChatThread(Number(purgeBtn.dataset.threadPurge)); return; }
const btn = e.target.closest('[data-thread]');
if (!btn) return;
selectChatThread(Number(btn.dataset.thread));
});
list.addEventListener('keydown', e => {
if (e.key !== 'Enter' && e.key !== ' ') return;
if (e.target.closest('[data-thread-hide],[data-thread-restore],[data-thread-purge]')) return;
const card = e.target.closest('.chat-thread[data-thread]');
if (!card || e.target !== card) return;
e.preventDefault();
selectChatThread(Number(card.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 (hiddenBtn) hiddenBtn.addEventListener('click', () => {
chatHiddenMode = !chatHiddenMode;
chatActiveId = 0;
renderChatHiddenBtn();
loadChatThreads();
});
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 (editModal && editModal.classList.contains('open')) { closeChatEditModal(); 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();
})();