feat(ui): restyle message composers

This commit is contained in:
admin i 2025-11-28 11:56:47 +03:00
parent 70d0260919
commit c1a907cd60
21 changed files with 1164 additions and 408 deletions

View File

@ -8,7 +8,7 @@ from django.db import transaction
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_POST
from django.views.decorators.http import require_GET, require_POST
from communications.models import (
Chat,
@ -17,6 +17,7 @@ from communications.models import (
MessageAttachment,
)
from communications.consumers import serialize_message
from communications.views import _coerce_ts, user_can_access_chat
@csrf_protect
@ -27,10 +28,6 @@ def upload_message_with_attachments(request):
chat_id = request.POST.get('chat_id')
content = request.POST.get('content', '').strip()
# Отладка
print(f"DEBUG: chat_id={chat_id}, content='{content}'")
print(f"DEBUG: FILES={list(request.FILES.keys())}")
if not chat_id:
return JsonResponse(
{'ok': False, 'error': 'chat_id required'},
@ -166,3 +163,68 @@ def upload_message_with_attachments(request):
'created_at': message.created_at.isoformat(),
'attachments': attachments
})
@login_required
@require_GET
def load_chat_messages(request, pk: int):
"""Постраничная загрузка истории чата (старые сообщения)."""
chat = get_object_or_404(Chat, pk=pk)
user = request.user
if not user_can_access_chat(chat, user):
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
try:
limit = int(request.GET.get("limit", 30))
except (TypeError, ValueError):
limit = 30
limit = max(1, min(limit, 100))
before_id = request.GET.get("before_id")
before_ts = request.GET.get("before_ts")
qs = (
Message.objects.filter(chat=chat)
.select_related("author")
.prefetch_related("attachments")
.order_by("-created_at")
)
if before_id:
boundary = (
Message.objects.filter(chat=chat, pk=before_id)
.only("created_at")
.first()
)
if boundary:
qs = qs.filter(created_at__lt=boundary.created_at)
elif before_ts:
boundary_ts = _coerce_ts(before_ts)
qs = qs.filter(created_at__lt=boundary_ts)
batch = list(qs[: limit + 1])
has_more = len(batch) > limit
next_cursor = None
if has_more:
next_cursor = batch[-1]
batch = batch[:-1]
elif batch:
next_cursor = batch[-1]
serialized = [serialize_message(m) for m in reversed(batch)]
return JsonResponse(
{
"ok": True,
"messages": serialized,
"has_more": has_more,
"next_before_id": next_cursor.id if next_cursor else None,
"next_before_ts": (
int(next_cursor.created_at.timestamp() * 1000)
if next_cursor
else None
),
}
)

View File

@ -10,7 +10,10 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
from .feed.views import CommentViewSet, PostViewSet
from .documents.views import DocumentViewSet
from .requests_app.views import RequestViewSet
from .communications.views import upload_message_with_attachments
from .communications.views import (
load_chat_messages,
upload_message_with_attachments,
)
app_name = "v1"
@ -24,9 +27,17 @@ router.register(r"requests", RequestViewSet, basename="request")
router.register(r"departments", DepartmentViewSet, basename="departments")
router.register(r"employees", EmployeeViewSet, basename="employees")
router.register(r"employee-actions", EmployeeActionViewSet, basename="employee-actions")
router.register(
r"employee-actions",
EmployeeActionViewSet,
basename="employee-actions",
)
router.register(r"positions", PositionViewSet, basename="positions")
router.register(r"department-roles", DepartmentRoleViewSet, basename="department-roles")
router.register(
r"department-roles",
DepartmentRoleViewSet,
basename="department-roles",
)
router.register(r"skills", SkillViewSet, basename="skills")
router.register(r"posts", PostViewSet, basename="posts")
router.register(r"comments", CommentViewSet, basename="comments")
@ -35,12 +46,25 @@ router.register(r"groups", GroupViewSet, basename="groups")
urlpatterns = [
path("auth/register/", RegisterAPIView.as_view(), name="register"),
path("auth/resend-email/", ResendEmailAPIView.as_view(), name="resend-email"),
path("auth/verify-email/", VerifyEmailAPIView.as_view(), name="verify-email"),
path(
"auth/resend-email/",
ResendEmailAPIView.as_view(),
name="resend-email",
),
path(
"auth/verify-email/",
VerifyEmailAPIView.as_view(),
name="verify-email",
),
path(
"communications/upload-message/",
upload_message_with_attachments,
name="upload_message"
),
path(
"communications/chats/<int:pk>/messages/",
load_chat_messages,
name="chat_messages",
),
path("", include(router.urls)),
]

View File

@ -53,6 +53,11 @@ def serialize_message(m: Message) -> dict:
"file_url": att.file.url,
"file_size": att.file_size,
"mime_type": att.mime_type,
"thumbnail": (
att.thumbnail.url
if getattr(att, "thumbnail", None)
else None
),
})
data["attachments"] = attachments
@ -64,20 +69,6 @@ def serialize_message(m: Message) -> dict:
"author_name": m.reply_to.author.get_full_name() if m.reply_to else ""
}
# Вложения (если есть)
if m.has_attachments:
attachments = []
for att in m.attachments.all():
attachments.append({
"id": att.id,
"file_name": att.file_name,
"file_type": att.file_type,
"file_url": att.file.url,
"file_size": att.file_size,
"thumbnail": att.thumbnail.url if att.thumbnail else None
})
data["attachments"] = attachments
# Информация о пересылке
if m.is_forwarded and hasattr(m, 'forward_info'):
fw = m.forward_info

View File

@ -4,6 +4,7 @@ import datetime
from datetime import datetime as dt
from datetime import timezone as dt_tz
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError, models
@ -18,7 +19,9 @@ from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, ListView
from .forms import MessageForm
from .models import Chat, ChatReadState, Message
from .models import Chat, ChatMembership, ChatReadState, Message
CHAT_MESSAGES_PAGE_SIZE = getattr(settings, "CHAT_MESSAGES_PAGE_SIZE", 50)
# ===== helpers =====
@ -47,6 +50,20 @@ def _coerce_ts(val: str | None) -> datetime.datetime:
return d
def user_can_access_chat(chat: Chat, user) -> bool:
if not chat or not user:
return False
if chat.type == "global":
return True
if chat.type == "private":
return chat.participants.filter(pk=user.pk).exists()
if chat.type == "department" and chat.department_id:
return chat.get_participants.filter(pk=user.pk).exists()
if chat.type in ["group", "channel", "announcement"]:
return ChatMembership.objects.filter(chat=chat, user=user).exists()
return False
class ChatListView(LoginRequiredMixin, ListView):
model = Chat
template_name = "communications/chat_list.html"
@ -113,30 +130,27 @@ class ChatListView(LoginRequiredMixin, ListView):
to_attr="last_message",
),
)
.order_by(models.F("last_msg_at").desc(nulls_last=True), "-created_at")
.order_by(
models.F("last_msg_at").desc(nulls_last=True),
"-created_at",
)
)
return qs
class ChatDetailView(LoginRequiredMixin, DetailView):
class ChatDetailView(LoginRequiredMixin, DetailView, FormView):
model = Chat
template_name = "communications/chat_detail.html"
context_object_name = "chat"
form_class = MessageForm
def get_success_url(self):
return reverse(
"communications:chat_detail", kwargs={"pk": self.object.pk}
)
def _user_has_access(self, chat: Chat, user) -> bool:
if chat.type == "global":
return True
if chat.type == "private":
return chat.participants.filter(pk=user.pk).exists()
if chat.type == "department" and chat.department_id:
return chat.get_participants.filter(pk=user.pk).exists()
if chat.type in ["group", "channel", "announcement"]:
# Проверяем membership для групповых чатов, каналов и объявлений
from communications.models import ChatMembership
return ChatMembership.objects.filter(
chat=chat, user=user
).exists()
return False
return user_can_access_chat(chat, user)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -150,9 +164,36 @@ class ChatDetailView(LoginRequiredMixin, DetailView):
raise Http404("Chat not found")
# сообщения + участники
context["messages"] = chat.messages.select_related("author").order_by(
"created_at"
messages_qs = (
chat.messages
.select_related("author")
.prefetch_related("attachments")
.order_by("-created_at")
)
page_size = CHAT_MESSAGES_PAGE_SIZE
raw_messages = list(messages_qs[: page_size + 1])
has_more = len(raw_messages) > page_size
next_cursor = None
if has_more:
next_cursor = raw_messages.pop()
displayed_messages = list(reversed(raw_messages))
oldest_displayed = raw_messages[-1] if raw_messages else next_cursor
context["messages"] = displayed_messages
context["messages_has_more"] = has_more
context["messages_oldest_id"] = (
oldest_displayed.id if oldest_displayed else None
)
context["messages_oldest_ts"] = (
int(oldest_displayed.created_at.timestamp() * 1000)
if oldest_displayed
else None
)
context["messages_page_size"] = page_size
context["form"] = context.get("form", MessageForm())
context["participants"] = chat.get_participants
# отдаём клиенту last_read_at и «первое непрочитанное»
@ -162,7 +203,9 @@ class ChatDetailView(LoginRequiredMixin, DetailView):
.first()
)
last_read_at = (
read_state.last_read_at if read_state and read_state.last_read_at else None
read_state.last_read_at
if read_state and read_state.last_read_at
else None
)
context["last_read_at"] = last_read_at
@ -177,14 +220,52 @@ class ChatDetailView(LoginRequiredMixin, DetailView):
)
context["first_unread_id"] = first_unread.id if first_unread else None
if first_unread:
context["unread_from_ts"] = int(first_unread.created_at.timestamp() * 1000)
context["unread_from_ts"] = int(
first_unread.created_at.timestamp() * 1000
)
elif last_read_at:
context["unread_from_ts"] = int(last_read_at.timestamp() * 1000) + 1
context["unread_from_ts"] = (
int(last_read_at.timestamp() * 1000) + 1
)
else:
context["unread_from_ts"] = None
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
user = request.user
if not ChatDetailView._user_has_access(self, self.object, user):
from django.http import HttpResponseForbidden
return HttpResponseForbidden("forbidden")
form = self.get_form()
if form.is_valid():
msg = form.save(commit=False)
msg.chat = self.object
msg.author = user
msg.save()
# автору сразу «прочитано» — делаем безопасно
ts = msg.created_at
updated = ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(
chat=self.object, user=user, last_read_at=ts
)
except IntegrityError:
ChatReadState.objects.filter(
chat=self.object, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return redirect(self.get_success_url())
return self.render_to_response(self.get_context_data(form=form))
def start_private_chat(request, employee_pk):
from employees.models import Employee
@ -202,7 +283,11 @@ def start_private_chat(request, employee_pk):
# новый чат помечаем прочитанным для создателя (нет непрочитанных)
now_ts = timezone.now()
try:
ChatReadState.objects.create(chat=chat, user=user, last_read_at=now_ts)
ChatReadState.objects.create(
chat=chat,
user=user,
last_read_at=now_ts,
)
except IntegrityError:
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=now_ts
@ -219,8 +304,10 @@ def chat_mark_read(request, pk: int):
# доступ
def has_access(c: Chat, u) -> bool:
if c.type == "global": return True
if c.type == "private": return c.participants.filter(pk=u.pk).exists()
if c.type == "global":
return True
if c.type == "private":
return c.participants.filter(pk=u.pk).exists()
if c.type == "department" and c.department_id:
return c.get_participants.filter(pk=u.pk).exists()
return False
@ -228,24 +315,41 @@ def chat_mark_read(request, pk: int):
if not has_access(chat, user):
return JsonResponse({"ok": False, "error": "forbidden"}, status=403)
# целевая точка времени: приоритет у upto_id, затем upto_ts, затем последний месседж
# целевая точка времени: приоритет у upto_id, затем upto_ts,
# затем последний месседж
ts = None
upto_id = request.POST.get("upto_id")
if upto_id:
m = Message.objects.filter(chat=chat, pk=upto_id).only("created_at").first()
m = (
Message.objects.filter(chat=chat, pk=upto_id)
.only("created_at")
.first()
)
ts = m.created_at if m else None
if ts is None:
ts = _coerce_ts(request.POST.get("upto_ts"))
if ts is None:
ts = chat.messages.order_by("-created_at").values_list("created_at", flat=True).first() or timezone.now()
ts = (
chat.messages.order_by("-created_at")
.values_list("created_at", flat=True)
.first()
or timezone.now()
)
# обновляем только если новее; без update_or_create — устойчиво к SQLite/гонкам
updated = ChatReadState.objects.filter(chat=chat, user=user, last_read_at__lt=ts).update(last_read_at=ts)
# обновляем только если новее; без update_or_create — устойчиво
# к SQLite/гонкам
updated = ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(chat=chat, user=user, last_read_at=ts)
except IntegrityError:
ChatReadState.objects.filter(chat=chat, user=user, last_read_at__lt=ts).update(last_read_at=ts)
ChatReadState.objects.filter(
chat=chat, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
return JsonResponse({"ok": True, "last_read_at": int(ts.timestamp() * 1000)})
return JsonResponse(
{"ok": True, "last_read_at": int(ts.timestamp() * 1000)}
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -153,7 +153,7 @@
────────────────────────────────────────────────────────────── */
.comment-new textarea {
resize: vertical;
max-height: 300px;
min-height: 100px;
max-height: 220px;
min-height: 44px;
overflow-y: auto;
}

View File

@ -1,230 +1,372 @@
import { createMessageElement } from './chatMessageTemplates.js';
/**
* ChatComposer.js
* Отвечает за логику ввода текста, выбора файлов и отправки сообщений.
* Полностью заменяет стандартную отправку формы.
* Chat Composer module
* Отвечает за ввод текста, прикрепление файлов и отправку сообщений через REST API.
*/
const DEFAULT_CONFIG = {
selector: '[data-chat-composer]',
textareaSelector: '#id_content',
previewSelector: '#attachmentPreview',
inputs: {
document: '#documentInput',
image: '#imageInput',
camera: '#cameraInput',
audio: '#audioInput'
},
submitSelector: '.btn-send',
uploadUrl: '/api/v1/communications/upload-message/',
csrfCookieName: 'csrftoken',
scrollSelector: '#chatScroll',
profileUrl: '/employees/profile/',
detailUrlTemplate: '/employees/0/'
};
const ICON_BY_MIME = [
{ test: (mime) => mime.startsWith('image/'), icon: 'bi-image text-success' },
{ test: (mime) => mime.startsWith('video/'), icon: 'bi-camera-video text-info' },
{ test: (mime) => mime.startsWith('audio/'), icon: 'bi-mic text-danger' },
{ test: (mime) => mime.includes('pdf'), icon: 'bi-file-pdf text-primary' }
];
class ChatComposer {
constructor(config) {
this.chatId = config.chatId;
this.apiEndpoint = config.apiEndpoint || '/api/v1/communications/upload-message/';
this.csrfToken = this.getCookie('csrftoken');
// DOM Elements
this.form = document.getElementById('chatForm');
this.textarea = document.getElementById('id_content');
this.previewContainer = document.getElementById('attachmentPreview');
// File Inputs
this.inputs = {
document: document.getElementById('documentInput'),
image: document.getElementById('imageInput'),
camera: document.getElementById('cameraInput'),
audio: document.getElementById('audioInput')
};
// Buttons
this.buttons = {
document: document.getElementById('attachDocument'),
image: document.getElementById('attachImage'),
camera: document.getElementById('attachCamera'),
audio: document.getElementById('attachAudio'),
send: this.form?.querySelector('.btn-send')
};
constructor(form, options = {}) {
this.form = form;
this.options = { ...DEFAULT_CONFIG, ...options };
this.chatId = Number(options.chatId || form.dataset.chatId || 0);
this.uploadUrl = options.uploadUrl || form.dataset.uploadUrl || DEFAULT_CONFIG.uploadUrl;
this.textarea = form.querySelector(this.options.textareaSelector);
this.preview = form.querySelector(this.options.previewSelector) || document.querySelector(this.options.previewSelector);
this.inputs = this.resolveInputs();
this.submitButton = form.querySelector(this.options.submitSelector);
this.csrfCookieName = this.options.csrfCookieName;
this.scrollEl = document.querySelector(this.options.scrollSelector);
this.meId = Number(options.meId || form.dataset.meId || this.scrollEl?.dataset.meId || 0);
this.meName = options.meName || form.dataset.meName || 'Вы';
this.meAvatar = options.meAvatar || form.dataset.meAvatar || this.scrollEl?.dataset.meAvatar || '';
this.profileUrl = options.profileUrl || form.dataset.profileUrl || this.options.profileUrl;
this.detailUrlTemplate = options.detailUrlTemplate || form.dataset.detailUrlTemplate || this.options.detailUrlTemplate;
this.selectedFiles = [];
this.typingThrottle = 1200;
this.lastTypingSent = 0;
this.isSubmitting = false;
this.pendingQueue = [];
this.boundMessageHandler = (event) => this.handleIncomingMessage(event);
// State
this.selectedFiles = [];
this.isSubmitting = false;
if (!this.chatId) {
console.warn('ChatComposer: chatId is missing');
return;
}
this.init();
}
this.bindEvents();
form.dataset.composerBound = '1';
}
init() {
if (!this.form) {
console.error('ChatComposer: Form not found');
return;
}
resolveInputs() {
const resolved = {};
Object.entries(this.options.inputs || {}).forEach(([key, selector]) => {
resolved[key] = document.querySelector(selector);
});
return resolved;
}
this.bindEvents();
console.log('ChatComposer: Initialized');
}
bindEvents() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
bindEvents() {
// 1. Блокировка стандартной отправки
this.form.addEventListener('submit', (e) => {
e.preventDefault();
e.stopPropagation();
this.handleSubmit();
});
['document', 'image', 'camera', 'audio'].forEach((type) => {
const btn = document.getElementById(`attach${capitalize(type)}`);
const input = this.inputs[type];
if (btn && input) {
btn.addEventListener('click', (event) => {
event.preventDefault();
input.click();
});
input.addEventListener('change', (event) => this.handleFileSelect(event));
}
});
// 2. Обработка кнопок вложений
if (this.buttons.document) this.buttons.document.addEventListener('click', (e) => { e.preventDefault(); this.inputs.document.click(); });
if (this.buttons.image) this.buttons.image.addEventListener('click', (e) => { e.preventDefault(); this.inputs.image.click(); });
if (this.buttons.camera) this.buttons.camera.addEventListener('click', (e) => { e.preventDefault(); this.inputs.camera.click(); });
if (this.buttons.audio) this.buttons.audio.addEventListener('click', (e) => { e.preventDefault(); this.inputs.audio.click(); });
if (this.textarea) {
this.textarea.addEventListener('input', () => {
this.handleTyping();
});
}
// 3. Обработка выбора файлов (change event)
Object.values(this.inputs).forEach(input => {
if (input) {
input.addEventListener('change', (e) => this.handleFileSelect(e.target.files));
}
});
window.addEventListener('chat:message-received', this.boundMessageHandler);
}
// 4. Enter для отправки (если не Shift+Enter)
this.textarea.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.form.dispatchEvent(new Event('submit'));
}
});
}
handleFileSelect(event) {
const files = Array.from(event.target.files || []);
if (!files.length) return;
handleFileSelect(files) {
if (!files || files.length === 0) return;
// Добавляем новые файлы к существующим
this.selectedFiles = [...this.selectedFiles, ...Array.from(files)];
// Очищаем input, чтобы можно было выбрать тот же файл снова
Object.values(this.inputs).forEach(input => { if(input) input.value = ''; });
this.updatePreview();
}
files.forEach((file) => {
const uuidSupported = window.crypto && typeof window.crypto.randomUUID === 'function';
const fileId = uuidSupported ? window.crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
this.selectedFiles.push({ id: fileId, file });
});
removeFile(index) {
this.selectedFiles.splice(index, 1);
this.updatePreview();
}
event.target.value = '';
this.renderPreview();
}
updatePreview() {
if (!this.previewContainer) return;
handleTyping() {
const now = Date.now();
if (now - this.lastTypingSent > this.typingThrottle) {
this.lastTypingSent = now;
if (window.chatWebSocketApi?.sendTyping) {
window.chatWebSocketApi.sendTyping();
}
}
}
if (this.selectedFiles.length === 0) {
this.previewContainer.classList.add('d-none');
this.previewContainer.innerHTML = '';
return;
}
async handleSubmit(event) {
event.preventDefault();
if (this.isSubmitting) return;
this.previewContainer.classList.remove('d-none');
this.previewContainer.innerHTML = '';
const content = (this.textarea?.value || '').trim();
if (!content && this.selectedFiles.length === 0) {
alert('Введите текст сообщения или прикрепите файл');
return;
}
this.selectedFiles.forEach((file, index) => {
const item = document.createElement('div');
item.className = 'attachment-item d-flex align-items-center gap-2 p-2 border rounded mb-2 bg-white';
let icon = 'bi-file-earmark';
if (file.type.startsWith('image/')) icon = 'bi-image';
else if (file.type.startsWith('video/')) icon = 'bi-camera-video';
else if (file.type.startsWith('audio/')) icon = 'bi-music-note';
item.innerHTML = `
<i class="${icon} fs-4 text-primary"></i>
<div class="flex-grow-1 text-truncate">
<div class="fw-semibold text-truncate">${file.name}</div>
<div class="small text-secondary">${this.formatFileSize(file.size)}</div>
</div>
<button type="button" class="btn btn-sm btn-ghost text-danger remove-file-btn">
<i class="bi-x-lg"></i>
</button>
`;
const csrfToken = getCookie(this.csrfCookieName);
if (!csrfToken) {
alert('CSRF токен не найден. Обновите страницу.');
return;
}
item.querySelector('.remove-file-btn').addEventListener('click', () => this.removeFile(index));
this.previewContainer.appendChild(item);
});
}
const formData = new FormData();
formData.append('chat_id', this.chatId);
formData.append('content', content);
this.selectedFiles.forEach((entry, index) => {
formData.append(`file_${index}`, entry.file, entry.file.name);
});
async handleSubmit() {
if (this.isSubmitting) return;
const shouldShowPending = this.selectedFiles.length > 0 && this.scrollEl;
let pendingId = null;
if (shouldShowPending) {
pendingId = this.renderPendingMessage(content);
}
const content = this.textarea.value.trim();
// Валидация
if (!content && this.selectedFiles.length === 0) {
// Можно добавить визуальный эффект ошибки
this.textarea.focus();
return;
}
try {
this.setLoading(true);
const response = await fetch(this.uploadUrl, {
method: 'POST',
body: formData,
headers: {
'X-CSRFToken': csrfToken
}
});
this.isSubmitting = true;
this.toggleLoading(true);
const payload = await response.json().catch(() => ({}));
try {
const formData = new FormData();
formData.append('chat_id', this.chatId);
formData.append('content', content); // Отправляем даже пустой текст
if (!response.ok || !payload.ok) {
const errorMessage = payload.error || 'Не удалось отправить сообщение';
throw new Error(errorMessage);
}
// Добавляем файлы
this.selectedFiles.forEach((file, index) => {
formData.append(`file_${index}`, file);
});
this.resetForm();
} catch (error) {
console.error('ChatComposer: submit failed', error);
alert(error.message || 'Ошибка при отправке сообщения');
if (pendingId) {
this.removePendingMessage(pendingId);
}
} finally {
this.setLoading(false);
}
}
console.log(`ChatComposer: Sending message. Content len: ${content.length}, Files: ${this.selectedFiles.length}`);
renderPendingMessage(content) {
if (!this.scrollEl) return null;
const pendingId = (window.crypto?.randomUUID?.() || `pending-${Date.now()}-${Math.random()}`).toString();
const attachments = this.selectedFiles.map((entry) => {
const objectUrl = URL.createObjectURL(entry.file);
return {
id: entry.id,
file_name: entry.file.name,
file_type: detectFileType(entry.file.type),
file_url: objectUrl,
file_size: entry.file.size,
mime_type: entry.file.type
};
});
const response = await fetch(this.apiEndpoint, {
method: 'POST',
headers: {
'X-CSRFToken': this.csrfToken
},
body: formData
});
const msg = {
id: pendingId,
author_id: this.meId,
author_name: this.meName,
author_url: this.profileUrl,
avatar: this.meAvatar,
content,
created_ts: Date.now(),
has_attachments: attachments.length > 0,
attachments,
is_pending: true
};
const result = await response.json();
const element = createMessageElement(msg, {
meId: this.meId,
profileUrl: this.profileUrl,
detailUrlTemplate: this.detailUrlTemplate
});
element.dataset.pendingId = pendingId;
this.scrollEl.appendChild(element);
this.scrollEl.scrollTop = this.scrollEl.scrollHeight;
if (response.ok && result.ok) {
this.resetForm();
} else {
console.error('ChatComposer: Server error', result);
alert('Ошибка отправки: ' + (result.error || 'Неизвестная ошибка'));
}
this.pendingQueue.push({
id: pendingId,
element,
objectUrls: attachments.map((att) => att.file_url)
});
return pendingId;
}
} catch (error) {
console.error('ChatComposer: Network error', error);
alert('Ошибка сети. Проверьте подключение.');
} finally {
this.isSubmitting = false;
this.toggleLoading(false);
this.textarea.focus();
}
}
handleIncomingMessage(event) {
const msg = event?.detail;
if (!msg) return;
if (Number(msg.chat_id || this.chatId) !== Number(this.chatId)) return;
if (Number(msg.author_id) !== Number(this.meId)) return;
this.resolvePendingMessage();
}
resetForm() {
this.textarea.value = '';
this.selectedFiles = [];
this.updatePreview();
// Сброс высоты textarea (если используется авто-высота)
this.textarea.style.height = 'auto';
}
resolvePendingMessage() {
const pending = this.pendingQueue.shift();
if (!pending) return;
pending.objectUrls.forEach((url) => URL.revokeObjectURL(url));
if (pending.element) {
pending.element.classList.add('message-pending--resolved');
pending.element.remove();
}
}
toggleLoading(isLoading) {
if (this.buttons.send) {
this.buttons.send.disabled = isLoading;
this.buttons.send.innerHTML = isLoading
? '<span class="spinner-border spinner-border-sm"></span>'
: '<i class="bi-send-fill"></i>';
}
}
removePendingMessage(pendingId) {
const index = this.pendingQueue.findIndex((item) => item.id === pendingId);
if (index === -1) return;
const [pending] = this.pendingQueue.splice(index, 1);
pending.objectUrls.forEach((url) => URL.revokeObjectURL(url));
pending.element?.remove();
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
renderPreview() {
if (!this.preview) return;
getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
if (this.selectedFiles.length === 0) {
this.preview.classList.add('d-none');
this.preview.innerHTML = '';
return;
}
this.preview.classList.remove('d-none');
this.preview.innerHTML = '';
this.selectedFiles.forEach((entry) => {
const iconClass = pickIcon(entry.file.type);
const wrapper = document.createElement('div');
wrapper.className = 'attachment-item d-flex align-items-center gap-2 p-2 rounded mb-2';
wrapper.innerHTML = `
<i class="${iconClass} fs-4"></i>
<div class="flex-grow-1">
<div class="fw-semibold text-truncate">${entry.file.name}</div>
<div class="small text-secondary">${formatFileSize(entry.file.size)}</div>
</div>
<button type="button" class="btn btn-sm btn-ghost" aria-label="Удалить файл" data-file-id="${entry.id}">
<i class="bi-x-lg"></i>
</button>
`;
wrapper.querySelector('button')?.addEventListener('click', () => {
this.removeFile(entry.id);
});
this.preview.appendChild(wrapper);
});
}
removeFile(entryId) {
this.selectedFiles = this.selectedFiles.filter((entry) => entry.id !== entryId);
this.renderPreview();
}
resetForm() {
if (this.textarea) {
this.textarea.value = '';
this.textarea.dispatchEvent(new Event('input'));
}
Object.values(this.inputs).forEach((input) => {
if (input) input.value = '';
});
this.selectedFiles = [];
this.renderPreview();
}
setLoading(state) {
this.isSubmitting = state;
if (!this.submitButton) return;
this.submitButton.disabled = state;
this.submitButton.setAttribute('aria-busy', state ? 'true' : 'false');
}
}
// Экспорт для использования
window.ChatComposer = ChatComposer;
function formatFileSize(bytes) {
if (!Number.isFinite(bytes)) return '';
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ГБ`;
}
function getCookie(name) {
const cookies = document.cookie ? document.cookie.split(';') : [];
for (const cookie of cookies) {
const trimmed = cookie.trim();
if (trimmed.startsWith(`${name}=`)) {
return decodeURIComponent(trimmed.substring(name.length + 1));
}
}
return null;
}
function pickIcon(mime = '') {
const match = ICON_BY_MIME.find((item) => item.test(mime));
return match ? match.icon : 'bi-file-earmark text-primary';
}
function capitalize(text = '') {
return text.charAt(0).toUpperCase() + text.slice(1);
}
function detectFileType(mime = '') {
if (!mime) return 'file';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('audio/')) return 'audio';
return 'file';
}
export function initChatComposer(config = {}) {
const forms = document.querySelectorAll(config.selector || DEFAULT_CONFIG.selector);
const instances = [];
forms.forEach((form) => {
if (form.dataset.composerBound === '1') return;
const instance = new ChatComposer(form, {
...config,
chatId: config.chatId || form.dataset.chatId,
uploadUrl: config.uploadUrl || form.dataset.uploadUrl
});
if (instance.form) {
instances.push(instance);
}
});
return instances;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initChatComposer());
} else {
initChatComposer();
}

View File

@ -0,0 +1,237 @@
import {
createMessageElement,
createDayDivider,
formatDay,
toTimestamp
} from './chatMessageTemplates.js';
const DEFAULT_CONFIG = {
scrollSelector: '#chatScroll',
loaderSelector: '[data-history-loader]',
triggerThreshold: 140,
pageSize: 30
};
function parseBool(value) {
if (typeof value === 'boolean') return value;
return value === '1' || value === 'true';
}
function getAvatarMapSnapshot() {
if (window?.chatAvatarMap?.getAll) {
return window.chatAvatarMap.getAll();
}
if (window?.__CHAT_AVATARS__) {
return { ...window.__CHAT_AVATARS__ };
}
return {};
}
function updateOldestDataset(scrollEl) {
const firstMsg = scrollEl.querySelector('.msg[data-id]');
if (!firstMsg) {
scrollEl.dataset.oldestId = '';
scrollEl.dataset.oldestTs = '';
return null;
}
scrollEl.dataset.oldestId = String(firstMsg.dataset.id || '');
scrollEl.dataset.oldestTs = String(firstMsg.dataset.ts || '');
return firstMsg;
}
function ensureLoader(scrollEl, selector) {
let loader = scrollEl.querySelector(selector);
if (!loader) {
loader = document.createElement('div');
loader.className = 'history-loader d-none';
loader.setAttribute('data-history-loader', '1');
loader.innerHTML = `
<div class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></div>
<span class="small">Загружаем ранние сообщения…</span>
`;
scrollEl.prepend(loader);
}
return loader;
}
export function initChatHistoryLoader(options = {}) {
const config = { ...DEFAULT_CONFIG, ...options };
const scrollEl = document.querySelector(config.scrollSelector);
if (!scrollEl) return null;
const fetchUrl = scrollEl.dataset.fetchUrl;
if (!fetchUrl) return null;
const loaderEl = ensureLoader(scrollEl, config.loaderSelector);
const meId = Number(scrollEl.dataset.meId || 0);
const profileUrl = scrollEl.dataset.profileUrl || '/employees/profile/';
const detailUrlTemplate = scrollEl.dataset.detailUrlTemplate || '/employees/0/';
const avatarMap = getAvatarMapSnapshot();
const state = {
loading: false,
hasMore: parseBool(scrollEl.dataset.hasMore || false),
oldestId: (() => {
const raw = scrollEl.dataset.oldestId;
if (!raw || raw === 'None') return null;
return raw;
})(),
fetchUrl,
limit: Number(scrollEl.dataset.pageSize || config.pageSize) || config.pageSize
};
function showLoader() {
loaderEl.classList.remove('d-none');
}
function hideLoader() {
loaderEl.classList.add('d-none');
}
function dedupeDayDividers(scrollEl) {
const dividerEls = scrollEl.querySelectorAll('.day-divider');
let lastLabel = null;
dividerEls.forEach((divider) => {
const label = divider.textContent.trim();
if (label === lastLabel) {
divider.remove();
} else {
lastLabel = label;
}
});
}
function prependMessages(messages = [], ctx) {
if (!messages.length) {
return;
}
const {
scrollEl,
loaderEl,
meId,
profileUrl,
detailUrlTemplate,
avatarMap,
state
} = ctx;
const prevHeight = scrollEl.scrollHeight;
const fragment = document.createDocumentFragment();
let prevDay = null;
messages.forEach((msg) => {
const ts = toTimestamp(msg);
const day = msg.day || formatDay(ts);
if (day !== prevDay) {
fragment.appendChild(createDayDivider(day));
}
prevDay = day;
const element = createMessageElement(msg, {
meId,
profileUrl,
detailUrlTemplate,
avatarMap
});
element.classList.remove('message-pending', 'message-pending--resolved');
fragment.appendChild(element);
});
const anchor = loaderEl.nextSibling || scrollEl.firstChild;
scrollEl.insertBefore(fragment, anchor);
const delta = scrollEl.scrollHeight - prevHeight;
scrollEl.scrollTop += delta;
dedupeDayDividers(scrollEl);
const firstMsg = updateOldestDataset(scrollEl);
if (firstMsg) {
state.oldestId = firstMsg.dataset.id;
}
}
async function loadMore() {
if (state.loading || !state.hasMore || !state.oldestId) {
return;
}
state.loading = true;
showLoader();
try {
const url = new URL(state.fetchUrl, window.location.origin);
url.searchParams.set('limit', String(state.limit));
url.searchParams.set('before_id', state.oldestId);
const response = await fetch(url.toString(), {
headers: {
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin'
});
const payload = await response.json();
if (!response.ok || !payload.ok) {
throw new Error(payload.error || 'Не удалось загрузить сообщения');
}
if (Array.isArray(payload.messages) && payload.messages.length) {
prependMessages(payload.messages, {
scrollEl,
loaderEl,
meId,
profileUrl,
detailUrlTemplate,
avatarMap,
state
});
state.oldestId = String(payload.messages[0].id);
} else if (payload.next_before_id) {
state.oldestId = String(payload.next_before_id);
} else {
state.oldestId = null;
}
state.hasMore = Boolean(payload.has_more);
scrollEl.dataset.hasMore = state.hasMore ? '1' : '0';
} catch (error) {
console.error('ChatHistoryLoader: history request failed', error);
} finally {
state.loading = false;
hideLoader();
}
}
function handleScroll() {
if (!state.hasMore || state.loading) {
return;
}
if (scrollEl.scrollTop <= config.triggerThreshold) {
loadMore();
}
}
scrollEl.addEventListener('scroll', handleScroll, { passive: true });
if (!state.hasMore) {
hideLoader();
} else if (scrollEl.scrollHeight <= scrollEl.clientHeight + config.triggerThreshold) {
// Если сообщений мало и страница «короткая», сразу догружаем ещё
loadMore();
}
return {
loadMore,
hasMore: () => state.hasMore
};
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initChatHistoryLoader());
} else {
initChatHistoryLoader();
}

View File

@ -129,11 +129,15 @@ export function createMessageElement(msg, options = {}) {
} = options;
const mine = Number(msg.author_id) === Number(meId);
const isPending = Boolean(msg.is_pending);
const ts = toTimestamp(msg);
// Создание обёртки
const wrap = document.createElement('div');
wrap.className = `d-flex mb-3 msg ${mine ? 'justify-content-end' : 'justify-content-start'}`;
if (isPending) {
wrap.classList.add('message-pending');
}
if (msg.id != null) {
wrap.setAttribute('data-id', String(msg.id));
@ -165,8 +169,8 @@ export function createMessageElement(msg, options = {}) {
<a href="${att.file_url}" target="_blank">
<img src="${att.file_url}"
alt="${esc(att.file_name)}"
class="img-fluid rounded"
style="max-width: 300px; max-height: 300px;">
loading="lazy"
class="chat-media chat-media--image rounded">
</a>`;
} else if (att.file_type === 'audio') {
attachmentsHTML += `
@ -176,8 +180,8 @@ export function createMessageElement(msg, options = {}) {
<div class="small text-secondary">${esc(att.file_name)}</div>`;
} else if (att.file_type === 'video') {
attachmentsHTML += `
<video controls preload="metadata" class="w-100" style="max-width: 400px;">
<source src="${att.file_url}" type="${att.mime_type}">
<video controls preload="metadata" playsinline class="chat-media chat-media--video">
<source src="${att.file_url}" type="${att.mime_type || 'video/mp4'}">
Ваш браузер не поддерживает видео.
</video>
<div class="small text-secondary">${esc(att.file_name)}</div>`;
@ -200,6 +204,10 @@ export function createMessageElement(msg, options = {}) {
attachmentsHTML += '</div>';
}
const pendingStatus = isPending
? '<div class="message-status small text-secondary mt-2"><span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Отправляем…</div>'
: '';
const bubble = `
<div class="d-flex flex-column" style="max-width:80%;">
<div class="small text-secondary ${mine ? 'text-end' : ''}">
@ -208,6 +216,7 @@ export function createMessageElement(msg, options = {}) {
<div class="mt-1 bubble ${mine ? 'bubble-me' : 'bubble-other'}">
${text ? text : ''}
${attachmentsHTML}
${pendingStatus}
</div>
</div>`;

View File

@ -38,7 +38,8 @@ export function initChatWebSocket(options = {}) {
profileUrl = '/employees/profile/',
detailUrlTemplate = '/employees/0/',
avatarMap = {},
markReadApi
markReadApi,
bindFormSubmit = true
} = options;
// Проверка обязательных параметров
@ -132,6 +133,13 @@ export function initChatWebSocket(options = {}) {
autoscroll();
}
// Сообщаем другим модулям о новом сообщении
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent('chat:message-received', { detail: msg })
);
}
// Обновляем observer для последнего чужого сообщения
if (observeLastForeign) {
observeLastForeign();
@ -196,19 +204,20 @@ export function initChatWebSocket(options = {}) {
/**
* Обрабатывает отправку формы
*/
form?.addEventListener('submit', (e) => {
const handleFormSubmit = (e) => {
e.preventDefault();
const text = (ta?.value || '').trim();
if (!text || ws.readyState !== WebSocket.OPEN) return;
// Отправка сообщения
ws.send(JSON.stringify({ content: text }));
// Очистка textarea
ta.value = '';
ta.dispatchEvent(new Event('input'));
});
};
if (bindFormSubmit && form) {
form.addEventListener('submit', handleFormSubmit);
}
/**
* Обрабатывает ввод в textarea (индикация "печатает...")

View File

@ -1,5 +1,5 @@
/**
* chat-detail.css
* chat-detail.scss
* Стили страницы детального чата: layout, scroll, bubbles, composer
*/
@ -12,7 +12,7 @@ main.main {
}
/* ===== Контейнер страницы чата ===== */
@supports(height: 100svh) {
@supports (height: 100svh) {
.chat-root {
height: calc(100svh - var(--navbar-h));
}
@ -137,74 +137,21 @@ main.main {
gap: var(--space-1);
}
.composer-input-group {
display: flex;
align-items: flex-end;
gap: var(--space-1);
flex-wrap: nowrap;
.chat-composer .message-field {
background: color-mix(in srgb, var(--bs-body-bg) 96%, transparent);
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
}
.composer-textarea-wrapper {
flex: 1 1 auto;
position: relative;
min-width: 0;
.chat-composer .message-input {
max-height: 180px;
}
.composer-textarea {
resize: none;
max-height: 120px;
border-radius: 20px;
padding: 10px 16px;
line-height: 1.4;
border: 1px solid var(--bs-border-color);
background: var(--bs-body-bg);
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
overflow-y: hidden; /* Убираем полоску прокрутки */
scrollbar-width: none; /* Firefox */
.chat-composer .btn-attachment {
color: var(--bs-body-color);
}
.composer-textarea::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.composer-textarea:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
}
.btn-attachment {
padding: 8px 12px;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-attachment:hover {
background: var(--bs-secondary-bg);
transform: scale(1.05);
}
.btn-send {
padding: 10px 16px;
border-radius: 50%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-send:hover {
transform: scale(1.05);
}
.btn-send i {
font-size: 1.1rem;
.chat-composer .btn-send {
box-shadow: 0 4px 16px rgba(var(--bs-primary-rgb), 0.35);
}
/* Превью вложений */
@ -266,9 +213,55 @@ main.main {
.message-attachments img,
.message-attachments audio {
max-width: 100%;
width: auto;
border-radius: 8px;
}
.chat-media {
display: inline-block;
width: auto !important;
max-width: min(640px, 85vw);
max-height: 520px;
height: auto !important;
border-radius: 12px;
background: rgba(0, 0, 0, 0.08);
content-visibility: auto;
}
.message-attachments .attachment-item > a,
.message-attachments .attachment-item > video,
.message-attachments .attachment-item > img {
display: inline-flex;
max-width: 100%;
}
.chat-media--video {
background: #000;
object-fit: contain;
}
.chat-media--image {
object-fit: cover;
}
.message-pending .chat-media {
min-width: 90px;
min-height: 64px;
}
.message-pending .bubble {
position: relative;
opacity: 0.8;
}
.message-pending .message-status {
color: rgba(255, 255, 255, 0.8);
}
.bubble-other .message-status {
color: rgba(0, 0, 0, 0.5);
}
.message-attachments .text-secondary {
color: inherit;
opacity: 0.7;
@ -287,6 +280,20 @@ main.main {
color: rgba(255, 255, 255, 0.9) !important;
}
.history-loader {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 0;
color: var(--bs-secondary-color);
}
.history-loader .spinner-border {
width: 1rem;
height: 1rem;
}
/* Выпадающее меню вложений */
.dropdown-menu {
min-width: 200px;
@ -337,7 +344,7 @@ main.main {
border-radius: 16px;
padding: var(--space-1) var(--space-2);
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, .06);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.bubble-me {
@ -376,7 +383,7 @@ main.main {
justify-content: center;
gap: var(--space-1);
margin: var(--space-1) 0 var(--space-2);
font-size: .85rem;
font-size: 0.85rem;
line-height: 1;
font-weight: 700;
pointer-events: none;

View File

@ -0,0 +1,158 @@
/**
* message-inputs.scss
* Унифицированные стили для полей ввода сообщений и комментариев
*/
$message-bg-default: color-mix(in srgb, var(--bs-body-bg) 95%, var(--bs-secondary-bg) 5%);
$message-bg-focused: color-mix(in srgb, var(--bs-body-bg) 85%, var(--bs-secondary-bg) 15%);
.message-field {
display: flex;
align-items: flex-end;
gap: var(--space-1);
width: 100%;
padding: 0.35rem 0.4rem;
border: 1px solid var(--bs-border-color);
border-radius: 999px;
background: $message-bg-default;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.message-field:focus-within {
border-color: var(--bs-primary);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
background: $message-bg-focused;
}
.message-field--stacked {
align-items: stretch;
flex-direction: column;
border-radius: 24px;
padding: 0.75rem;
gap: var(--space-1);
}
.message-field--compact {
padding: 0.25rem 0.3rem;
border-radius: 24px;
}
.message-input,
.composer-textarea {
flex: 1 1 auto;
min-height: 44px;
max-height: 260px;
border: none;
background: transparent;
resize: none;
padding: 0.5rem 0.75rem;
line-height: 1.4;
font-size: 1rem;
color: inherit;
scrollbar-width: none;
}
.message-field--compact .message-input {
min-height: 32px;
font-size: 0.95rem;
}
.message-field--stacked .message-input {
min-height: 96px;
padding: 0.35rem 0;
}
.message-input:focus {
outline: none;
box-shadow: none;
}
.message-input::-webkit-scrollbar {
display: none;
}
.message-icon-btn,
.btn-attachment,
.btn-send,
.message-send {
width: 42px;
height: 42px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: var(--bs-secondary-color);
transition: background-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}
.message-field--compact .message-icon-btn,
.message-field--compact .message-send {
width: 36px;
height: 36px;
}
.message-icon-btn:hover,
.btn-attachment:hover {
background: var(--bs-secondary-bg);
color: var(--bs-body-color);
transform: scale(1.02);
}
.message-send,
.btn-send {
background: var(--bs-primary);
color: #fff;
box-shadow: 0 4px 12px rgba(var(--bs-primary-rgb), 0.2);
}
.message-send:hover,
.btn-send:hover {
background: color-mix(in srgb, var(--bs-primary) 93%, white 7%);
color: #fff;
}
.message-send:disabled,
.btn-send:disabled {
opacity: 0.65;
box-shadow: none;
}
.message-attach {
position: relative;
}
.message-attach .dropdown-menu {
margin-top: 0.35rem;
border-radius: 16px;
padding: 0.5rem;
}
.message-field--solo {
display: block;
border-radius: 24px;
padding: 0.75rem 1rem;
}
.message-field--solo .message-input {
width: 100%;
min-height: 120px;
padding: 0;
}
@media (max-width: 576px) {
.message-field {
flex-wrap: wrap;
border-radius: 28px;
}
.message-send,
.btn-send,
.message-icon-btn,
.btn-attachment {
width: 38px;
height: 38px;
}
}

View File

@ -138,3 +138,6 @@ $badge-padding-x: 0.5rem !default; // var(--space-1)
// 4. КАСТОМНЫЕ СТИЛИ (если нужно)
// =============================================================================
// Здесь можно добавить дополнительные стили или переопределения
@import "components/message-inputs";
@import "components/chat-detail";

View File

@ -152,8 +152,14 @@
profileUrl: "{% url 'employees:profile' %}",
detailUrlTemplate: "{% url 'employees:employee_detail' 0 %}",
avatarMap: avatarMap.getAll(),
markReadApi: markReadApi
markReadApi: markReadApi,
bindFormSubmit: false
});
if (chatWebSocket) {
window.chatWebSocketApi = chatWebSocket;
window.dispatchEvent(new CustomEvent('chat:ws-ready', { detail: chatWebSocket }));
}
{% endif %}
// Глобальный обновлятель бейджа в сайдбаре

View File

@ -3,9 +3,7 @@
{% block title %}Чат{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/chat-detail.css' %}">
{% endblock %}
{% block extra_css %}{% endblock %}
{% block content %}
<div class="container chat-root">
@ -52,8 +50,20 @@
<div id="chatScroll"
class="chat-scroll"
data-me-id="{{ user.id }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}"
data-chat-id="{{ chat.id }}"
data-fetch-url="{% url 'api:v1:chat_messages' chat.pk %}"
data-page-size="{{ messages_page_size }}"
data-oldest-id="{{ messages_oldest_id }}"
data-oldest-ts="{{ messages_oldest_ts }}"
data-has-more="{% if messages_has_more %}1{% else %}0{% endif %}"
data-last-read-ts="{% if last_read_at %}{{ last_read_at|date:'U' }}000{% endif %}">
<div class="history-loader d-none" data-history-loader>
<div class="spinner-border spinner-border-sm text-secondary" role="status" aria-hidden="true"></div>
<span class="small">Загружаем ранние сообщения…</span>
</div>
{% if messages %}
{% for message in messages %}
{% ifchanged message.created_at|date:"d.m.Y" %}
@ -95,11 +105,11 @@
{% for attachment in message.attachments.all %}
<div class="attachment-item mb-2">
{% if attachment.file_type == 'image' %}
<a href="{{ attachment.file.url }}" target="_blank">
<img src="{{ attachment.file.url }}"
<a href="{{ attachment.file.url }}" target="_blank" class="d-inline-block">
<img src="{{ attachment.file.url }}"
alt="{{ attachment.file_name }}"
class="img-fluid rounded"
style="max-width: 300px; max-height: 300px;">
loading="lazy"
class="chat-media chat-media--image">
</a>
{% elif attachment.file_type == 'audio' %}
<audio controls class="w-100">
@ -107,7 +117,7 @@
</audio>
<div class="small text-secondary">{{ attachment.file_name }}</div>
{% elif attachment.file_type == 'video' %}
<video controls preload="metadata" class="w-100" style="max-width: 400px;">
<video controls preload="metadata" playsinline class="chat-media chat-media--video">
<source src="{{ attachment.file.url }}" type="{{ attachment.mime_type }}">
Ваш браузер не поддерживает видео.
</video>
@ -148,11 +158,23 @@
<!-- Композер -->
<div class="chat-composer">
<div id="typing" class="small text-secondary ps-1 pt-1 d-none">Собеседник печатает…</div>
<form id="chatForm" class="composer-wrap" action="javascript:void(0);" onsubmit="return false;">
<form id="chatForm"
class="composer-wrap"
method="post"
action="{% url 'communications:chat_detail' chat.pk %}"
enctype="multipart/form-data"
data-chat-composer="1"
data-chat-id="{{ chat.id }}"
data-upload-url="{% url 'api:v1:upload_message' %}"
data-me-id="{{ user.id }}"
data-me-name="{{ user.get_full_name|default:user.username }}"
data-me-avatar="{% if user.avatar %}{{ user.avatar.url }}{% endif %}"
data-profile-url="{% url 'employees:profile' %}"
data-detail-url-template="{% url 'employees:employee_detail' 0 %}">
{% csrf_token %}
<div class="composer-input-group">
<div class="message-field">
<!-- Кнопка вложений с выпадающим меню -->
<div class="dropdown">
<div class="dropdown message-attach">
<button type="button" class="btn btn-ghost btn-attachment"
id="attachmentDropdown"
data-bs-toggle="dropdown"
@ -187,27 +209,25 @@
</li>
</ul>
</div>
<!-- Скрытые input для файлов -->
<input type="file" id="documentInput" class="d-none" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar">
<input type="file" id="imageInput" class="d-none" accept="image/*,video/*" multiple>
<input type="file" id="cameraInput" class="d-none" accept="image/*" capture="environment">
<input type="file" id="audioInput" class="d-none" accept="audio/*">
<!-- Поле ввода текста -->
<div class="composer-textarea-wrapper">
<textarea name="content"
id="id_content"
class="form-control composer-textarea"
rows="1"
placeholder="Напишите сообщение…"></textarea>
</div>
<textarea name="content"
id="id_content"
class="form-control composer-textarea message-input"
rows="1"
placeholder="Напишите сообщение…"></textarea>
<!-- Кнопка отправки -->
<button type="submit" class="btn btn-primary btn-send" title="Отправить">
<button type="submit" class="btn btn-primary btn-send message-send" title="Отправить">
<i class="bi-send-fill"></i>
</button>
</div>
<!-- Скрытые input для файлов -->
<input type="file" id="documentInput" class="d-none" accept=".pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar">
<input type="file" id="imageInput" class="d-none" accept="image/*,video/*" multiple>
<input type="file" id="cameraInput" class="d-none" accept="image/*" capture="environment">
<input type="file" id="audioInput" class="d-none" accept="audio/*">
<!-- Превью прикрепленных файлов -->
<div id="attachmentPreview" class="attachment-preview d-none"></div>
@ -219,31 +239,10 @@
</div>
</div>
{% block extra_js %}
<script src="{% static 'js/components/chatAvatarMap.js' %}"></script>
<script src="{% static 'js/components/chatMessageTemplates.js' %}"></script>
<script src="{% static 'js/components/chatWebSocket.js' %}"></script>
<script src="{% static 'js/components/chatComposer.js' %}"></script>
<script src="{% static 'js/chat-detail-enhanced.js' %}"></script>
<script src="{% static 'js/components/textareaAutogrow.js' %}"></script>
<script>
// Инициализация компонентов
document.addEventListener('DOMContentLoaded', function() {
// 1. Авто-высота поля ввода
if (window.initTextareaAutogrow) {
window.initTextareaAutogrow();
}
// 2. Композер сообщений (отправка текста и файлов)
if (window.ChatComposer) {
const composer = new ChatComposer({
chatId: '{{ chat.id }}',
apiEndpoint: '/api/v1/communications/upload-message/'
});
} else {
console.error('ChatComposer not loaded');
}
});
</script>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<script type="module" src="{% static 'js/components/chatComposer.js' %}"></script>
<script type="module" src="{% static 'js/components/chatHistoryLoader.js' %}"></script>
{% endblock %}

View File

@ -243,9 +243,9 @@
<form class="px-3 pb-3 pt-2" method="post" action="{% url 'feed:post_detail' post_id %}">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<div class="input-group">
<textarea name="text" class="form-control" rows="1" placeholder="Оставить комментарий…"></textarea>
<button class="btn btn-primary" type="submit"><i class="bi-send"></i></button>
<div class="message-field message-field--compact">
<textarea name="text" class="form-control message-input" rows="1" placeholder="Оставить комментарий…"></textarea>
<button class="btn btn-primary message-send" type="submit"><i class="bi-send"></i></button>
</div>
</form>
{% endif %}

View File

@ -255,9 +255,9 @@
<h6 class="mb-2">Оставить комментарий</h6>
<form method="post" action="">
{% csrf_token %}
<div class="input-group">
<textarea name="text" class="form-control" rows="2" placeholder="Напишите комментарий…"></textarea>
<button class="btn btn-primary" type="submit">
<div class="message-field">
<textarea name="text" class="form-control message-input" rows="2" placeholder="Напишите комментарий…"></textarea>
<button class="btn btn-primary message-send" type="submit">
<i class="bi-send"></i>
</button>
</div>

View File

@ -132,9 +132,9 @@
{# Добавить комментарий — #}
<form class="mt-2" method="post" action="{% url 'requests_app:request_comment_add' request_obj.pk %}">
{% csrf_token %}
<div class="input-group">
<textarea name="text" class="form-control" rows="1" placeholder="Оставить комментарий…" maxlength="1000"></textarea>
<button class="btn btn-primary" type="submit"><i class="bi-send"></i></button>
<div class="message-field message-field--compact">
<textarea name="text" class="form-control message-input" rows="1" placeholder="Оставить комментарий…" maxlength="1000"></textarea>
<button class="btn btn-primary message-send" type="submit"><i class="bi-send"></i></button>
</div>
</form>
</div>

View File

@ -156,9 +156,9 @@
<div class="comments-list" data-role="list"></div>
<form class="comment-new mt-3" data-role="form" autocomplete="off">
{% csrf_token %}
<div class="input-group input-group-sm">
<textarea class="form-control" rows="3" placeholder="Написать…" data-role="text"></textarea>
<button class="btn btn-outline-primary" type="submit" title="Отправить"><i class="bi-send"></i></button>
<div class="message-field message-field--compact">
<textarea class="form-control message-input" rows="3" placeholder="Написать…" data-role="text"></textarea>
<button class="btn btn-outline-primary message-send" type="submit" title="Отправить"><i class="bi-send"></i></button>
</div>
</form>
</div>
@ -229,7 +229,9 @@
<div class="mt-3">
<label class="form-label">Комментарий</label>
<textarea name="comment" class="form-control" rows="3"></textarea>
<div class="message-field message-field--solo">
<textarea name="comment" class="form-control message-input" rows="3"></textarea>
</div>
</div>
<div class="mt-3">
@ -275,10 +277,12 @@
<!-- ✅ значение по умолчанию — submit -->
<input type="hidden" name="_save_as" value="submit">
<div class="mb-3">
<label class="form-label">Тип</label>
<select name="type" id="editType" class="form-select">
<option value="">— Без изменения —</option>
<div class="mt-3">
<label class="form-label" for="editComment">Комментарий</label>
<div class="message-field message-field--solo">
<textarea name="comment" id="editComment" class="form-control message-input" rows="3"></textarea>
</div>
</div>
<option value="vacation">Отпуск</option>
<option value="sick_leave">Больничный</option>
<option value="transfer">Перевод</option>
@ -421,7 +425,9 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<textarea name="text" id="commentText" class="form-control" rows="4" required></textarea>
<div class="message-field message-field--solo">
<textarea name="text" id="commentText" class="form-control message-input" rows="4" required></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>

View File

@ -196,12 +196,11 @@
method="post"
action="{% url 'requests_app:request_comment_add' request_obj.pk %}?next={{ request.get_full_path|urlencode }}#comments">
{% csrf_token %}
<div class="input-group">
<textarea name="text" class="form-control" rows="1"
<div class="message-field message-field--compact">
<textarea name="text" class="form-control message-input" rows="1"
placeholder="Оставить комментарий…"
maxlength="{{ max_comment_len|default:1000 }}"></textarea>
<button class="btn btn-primary" type="submit">
<button class="btn btn-primary message-send" type="submit">
<i class="bi-send"></i>
</button>
</div>