fix(chat): валидная разметка карточки диалога и доступ с клавиатуры
Карточка треда была вложенными <button>, поэтому кнопки действий внутри неё ломали HTML-структуру. Заменена на <div role="button" tabindex="0"> с обработкой Enter/Space; классы действий переименованы в chat-thread-act / chat-thread-act-danger, чтобы не путаться с .chat-act. Также ужата шапка списка диалогов: заголовок не сжимается, кнопки не переносятся, подпись у кнопки скрытых заменена на бейдж.
This commit is contained in:
@@ -170,7 +170,7 @@ This document defines how AI agents should work with the WhatIDo codebase. Follo
|
|||||||
- **Авто-восстановление**: сообщение от тьютора снимает `deleted_at`/`purge_at` в том же `UPDATE chat_threads` при отправке (`CASE WHEN $5::boolean THEN NULL ...`, `byTutor` = `req.user.role !== 'admin'`) и публикует `thread_update`. Иначе переписка тьютора осталась бы невидимой для админа
|
- **Авто-восстановление**: сообщение от тьютора снимает `deleted_at`/`purge_at` в том же `UPDATE chat_threads` при отправке (`CASE WHEN $5::boolean THEN NULL ...`, `byTutor` = `req.user.role !== 'admin'`) и публикует `thread_update`. Иначе переписка тьютора осталась бы невидимой для админа
|
||||||
- **Ответ `GET /api/chat/threads`** дополнительно отдаёт `hidden` (число скрытых, только для админа в обычном режиме) и `hidden_mode` (был ли запрошен `?deleted=1`)
|
- **Ответ `GET /api/chat/threads`** дополнительно отдаёт `hidden` (число скрытых, только для админа в обычном режиме) и `hidden_mode` (был ли запрошен `?deleted=1`)
|
||||||
- `chatUnread()` для админа суммирует только `deleted_at IS NULL` — скрытые не мозолят бейдж `#sidebarChatBadge`
|
- `chatUnread()` для админа суммирует только `deleted_at IS NULL` — скрытые не мозолят бейдж `#sidebarChatBadge`
|
||||||
- **UI скрытия** в `#chatSide`: кнопка `#chatHiddenBtn` (переключает `chatHiddenMode` в `public/js/chat.js`) с бейджем `#chatHiddenCount` и подсказкой `#chatHiddenHint`; в карточке треда кнопки `data-thread-hide` / `data-thread-restore` / `data-thread-purge` (класс `chat-act`, иконки `archive` / `archive-restore` / `trash-2`). Обработчики висят на делегировании в `initChatPage()` и **обязаны** идти раньше ветки `closest('[data-thread]')` с `stopPropagation()` — иначе клик по действию выберет тред
|
- **UI скрытия** в `#chatSide`: кнопка `#chatHiddenBtn` (переключает `chatHiddenMode` в `public/js/chat.js`) с бейджем `#chatHiddenCount` и подсказкой `#chatHiddenHint`; в карточке треда кнопки `data-thread-hide` / `data-thread-restore` / `data-thread-purge` (обёртка `chat-thread-acts`, кнопки `chat-thread-act`, опасная `chat-thread-act-danger`, иконки `archive` / `archive-restore` / `trash-2`). Обработчики висят на делегировании в `initChatPage()` и **обязаны** идти раньше ветки `closest('[data-thread]')` с `stopPropagation()` — иначе клик по действию выберет тред
|
||||||
- **Правка и удаление сообщений**: `chat_messages.edited_at` / `deleted_at`. Маршруты `PUT`/`DELETE /api/chat/threads/:id/messages/:messageId` под `requireAuth, chatLimiter`, доступ через `chatMessageFor()` (сначала `chatThreadFor`, затем проверка, что сообщение принадлежит этому треду). Править может **только автор** (403 для админа на чужом сообщении), удалять — автор **или** админ (модерация). Обе операции работают по `deleted_at IS NULL`: правка удалённого — 400, повторное удаление — 404
|
- **Правка и удаление сообщений**: `chat_messages.edited_at` / `deleted_at`. Маршруты `PUT`/`DELETE /api/chat/threads/:id/messages/:messageId` под `requireAuth, chatLimiter`, доступ через `chatMessageFor()` (сначала `chatThreadFor`, затем проверка, что сообщение принадлежит этому треду). Править может **только автор** (403 для админа на чужом сообщении), удалять — автор **или** админ (модерация). Обе операции работают по `deleted_at IS NULL`: правка удалённого — 400, повторное удаление — 404
|
||||||
- **Удаление мягкое**: тело затирается (`body = ''`), вложения удаляются каскадом из БД, файлы — через `safeUnlink` по `chat_attachments.path` (до или после коммита, `safeUnlink` идемпотентен). В ответе `GET /messages` удалённое сообщение отдаётся с `deleted_at`, пустым `body` и `files: []` — фронтенд рисует «Сообщение удалено»
|
- **Удаление мягкое**: тело затирается (`body = ''`), вложения удаляются каскадом из БД, файлы — через `safeUnlink` по `chat_attachments.path` (до или после коммита, `safeUnlink` идемпотентен). В ответе `GET /messages` удалённое сообщение отдаётся с `deleted_at`, пустым `body` и `files: []` — фронтенд рисует «Сообщение удалено»
|
||||||
- **Превью треда после правки/удаления** пересчитывает `refreshChatThreadPreview()`: берётся последнее неудалённое сообщение, `last_message_text` = текст или `Вложение: <имя>`, при отсутствии сообщений — `NULL`. Счётчик непрочитанных уменьшается на 1 у той стороны, которой принадлежало удалённое сообщение (`GREATEST(col - 1, 0)`)
|
- **Превью треда после правки/удаления** пересчитывает `refreshChatThreadPreview()`: берётся последнее неудалённое сообщение, `last_message_text` = текст или `Вложение: <имя>`, при отсутствии сообщений — `NULL`. Счётчик непрочитанных уменьшается на 1 у той стороны, которой принадлежало удалённое сообщение (`GREATEST(col - 1, 0)`)
|
||||||
|
|||||||
+10
-10
@@ -909,10 +909,10 @@ body.report-card-open .report-fields{display:flex}
|
|||||||
.chat-layout{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
|
.chat-layout{display:grid;grid-template-columns:300px 1fr;gap:16px;align-items:start}
|
||||||
.chat-layout.chat-layout-solo{grid-template-columns:1fr}
|
.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{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-head{display:flex;align-items:center;gap:6px;padding:10px 10px;border-bottom:1px solid var(--border)}
|
||||||
.chat-side-title{font-size:.85rem;font-weight:600}
|
.chat-side-title{font-size:.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||||
.chat-side-head .grow{flex:1}
|
.chat-side-head .grow{flex:1}
|
||||||
.chat-new-btn{padding:7px 12px;font-size:.8rem}
|
.chat-new-btn{padding:7px 10px;font-size:.78rem;gap:6px;flex-shrink:0;white-space:nowrap}
|
||||||
.chat-new-btn svg{width:14px;height:14px}
|
.chat-new-btn svg{width:14px;height:14px}
|
||||||
.chat-thread-list{overflow-y:auto;display:flex;flex-direction:column}
|
.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-side-empty{padding:22px 14px;color:var(--muted);font-size:.85rem;text-align:center}
|
||||||
@@ -930,7 +930,7 @@ body.report-card-open .report-fields{display:flex}
|
|||||||
.chat-thread-preview{font-size:.78rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.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-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-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-side-toggle{padding:7px 12px;font-size:.8rem;display:inline-flex;align-items:center;gap:6px}
|
.chat-side-toggle{padding:7px 10px;font-size:.8rem;display:inline-flex;align-items:center;gap:6px;flex-shrink:0;white-space:nowrap}
|
||||||
.chat-side-toggle svg{width:14px;height:14px}
|
.chat-side-toggle svg{width:14px;height:14px}
|
||||||
.chat-side-toggle.on{background:rgba(37,99,235,.1);border-color:var(--accent);color:var(--accent)}
|
.chat-side-toggle.on{background:rgba(37,99,235,.1);border-color:var(--accent);color:var(--accent)}
|
||||||
.chat-hidden-count{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--muted);color:var(--card);font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
.chat-hidden-count{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--muted);color:var(--card);font-size:.62rem;font-weight:700;line-height:16px;text-align:center}
|
||||||
@@ -939,12 +939,12 @@ body.report-card-open .report-fields{display:flex}
|
|||||||
.chat-side-hint[hidden]{display:none}
|
.chat-side-hint[hidden]{display:none}
|
||||||
.chat-thread-acts{display:flex;align-items:center;gap:4px;margin-left:auto;align-self:center;opacity:0;transition:opacity .15s}
|
.chat-thread-acts{display:flex;align-items:center;gap:4px;margin-left:auto;align-self:center;opacity:0;transition:opacity .15s}
|
||||||
.chat-thread:hover .chat-thread-acts,.chat-thread:focus-visible .chat-thread-acts,.chat-thread.active .chat-thread-acts{opacity:1}
|
.chat-thread:hover .chat-thread-acts,.chat-thread:focus-visible .chat-thread-acts,.chat-thread.active .chat-thread-acts{opacity:1}
|
||||||
.chat-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;padding:0;flex-shrink:0}
|
.chat-thread-act{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;background:var(--bg);border:1px solid var(--border);border-radius:8px;color:var(--muted);cursor:pointer;padding:0;flex-shrink:0}
|
||||||
.chat-act:hover{color:var(--accent);border-color:var(--accent);background:rgba(37,99,235,.07)}
|
.chat-thread-act:hover{color:var(--accent);border-color:var(--accent);background:rgba(37,99,235,.07)}
|
||||||
.chat-act:hover svg{stroke:var(--accent)}
|
.chat-thread-act:hover svg{stroke:var(--accent)}
|
||||||
.chat-act-danger:hover{color:#ef4444;border-color:#ef4444;background:rgba(239,68,68,.08)}
|
.chat-thread-act-danger:hover{color:#ef4444;border-color:#ef4444;background:rgba(239,68,68,.08)}
|
||||||
.chat-act-danger:hover svg{stroke:#ef4444}
|
.chat-thread-act-danger:hover svg{stroke:#ef4444}
|
||||||
.chat-act svg{width:14px;height:14px}
|
.chat-thread-act svg{width:14px;height:14px}
|
||||||
.chat-thread.is-hidden .chat-thread-avatar{opacity:.5}
|
.chat-thread.is-hidden .chat-thread-avatar{opacity:.5}
|
||||||
.chat-thread.is-hidden .chat-thread-name,.chat-thread.is-hidden .chat-thread-preview{opacity:.65}
|
.chat-thread.is-hidden .chat-thread-name,.chat-thread.is-hidden .chat-thread-preview{opacity:.65}
|
||||||
.chat-hidden-tag{border-color:rgba(239,68,68,.35);color:#ef4444}
|
.chat-hidden-tag{border-color:rgba(239,68,68,.35);color:#ef4444}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
<div class="chat-side-head">
|
<div class="chat-side-head">
|
||||||
<span class="chat-side-title">Диалоги</span>
|
<span class="chat-side-title">Диалоги</span>
|
||||||
<span class="grow"></span>
|
<span class="grow"></span>
|
||||||
<button class="btn-primary ghost chat-side-toggle" id="chatHiddenBtn" type="button" hidden title="Показать скрытые диалоги"><i data-lucide="archive"></i> Скрытые <span class="chat-hidden-count" id="chatHiddenCount" hidden></span></button>
|
<button class="btn-primary ghost chat-side-toggle" id="chatHiddenBtn" type="button" hidden title="Показать скрытые диалоги"><i data-lucide="archive"></i><span class="chat-hidden-count" id="chatHiddenCount" hidden></span></button>
|
||||||
<button class="btn-primary ghost chat-new-btn" id="chatNewBtn" type="button" hidden title="Начать диалог с тьютором"><i data-lucide="user-plus"></i> Начать диалог</button>
|
<button class="btn-primary ghost chat-new-btn" id="chatNewBtn" type="button" hidden title="Начать диалог с тьютором"><i data-lucide="user-plus"></i> Начать диалог</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="chat-side-hint" id="chatHiddenHint" hidden>Скрытые диалоги. Новое сообщение от тьютора вернёт диалог в список.</div>
|
<div class="chat-side-hint" id="chatHiddenHint" hidden>Скрытые диалоги. Новое сообщение от тьютора вернёт диалог в список.</div>
|
||||||
|
|||||||
+13
-5
@@ -70,13 +70,13 @@ function chatThreadCardHtml(t) {
|
|||||||
const hidden = t.deleted_at ? ' is-hidden' : '';
|
const hidden = t.deleted_at ? ' is-hidden' : '';
|
||||||
const acts = isAdmin() ? (t.deleted_at
|
const acts = isAdmin() ? (t.deleted_at
|
||||||
? `<span class="chat-thread-acts">
|
? `<span class="chat-thread-acts">
|
||||||
<button type="button" class="chat-act" data-thread-restore="${t.id}" title="Вернуть диалог в список"><i data-lucide="archive-restore"></i></button>
|
<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-act chat-act-danger" data-thread-purge="${t.id}" title="Удалить диалог безвозвратно"><i data-lucide="trash-2"></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>`
|
||||||
: `<span class="chat-thread-acts">
|
: `<span class="chat-thread-acts">
|
||||||
<button type="button" class="chat-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
|
<button type="button" class="chat-thread-act" data-thread-hide="${t.id}" title="Скрыть диалог"><i data-lucide="archive"></i></button>
|
||||||
</span>`) : '';
|
</span>`) : '';
|
||||||
return `<button type="button" class="chat-thread${active}${hidden}${unread ? ' unread' : ''}" data-thread="${t.id}">
|
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-avatar">${esc((t.tutor_name || '?').slice(0, 1).toUpperCase())}</span>
|
||||||
<span class="chat-thread-body">
|
<span class="chat-thread-body">
|
||||||
<span class="chat-thread-top">
|
<span class="chat-thread-top">
|
||||||
@@ -90,7 +90,7 @@ function chatThreadCardHtml(t) {
|
|||||||
</span>
|
</span>
|
||||||
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
|
${unread ? `<span class="chat-thread-badge">${unread > 99 ? '99+' : unread}</span>` : ''}
|
||||||
${acts}
|
${acts}
|
||||||
</button>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatThreads() {
|
function renderChatThreads() {
|
||||||
@@ -708,6 +708,14 @@ function initChatPage() {
|
|||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
selectChatThread(Number(btn.dataset.thread));
|
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) {
|
if (attachBox) {
|
||||||
attachBox.addEventListener('click', e => {
|
attachBox.addEventListener('click', e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user