feat(chat): личный чат тьютор ↔ админ с вложениями
Диалоги 1:1 (один тред на тьютора), отдельный SSE-канал whatido:chat вместо общей ленты notifications, вложения с белым списком расширений и вставкой из буфера (Ctrl+V), очистка по chat_retention_days. Таблицы в бэкап, chat_attachments добавлен в sweepOrphanedUploads.
This commit is contained in:
@@ -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%}
|
||||
}
|
||||
|
||||
@@ -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 || '')}">
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user