feat(ui): restyle message composers
This commit is contained in:
parent
70d0260919
commit
c1a907cd60
@ -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
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@ -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)),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)}
|
||||
)
|
||||
|
||||
2
backend/static/css/bootstrap-custom.css
vendored
2
backend/static/css/bootstrap-custom.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -153,7 +153,7 @@
|
||||
────────────────────────────────────────────────────────────── */
|
||||
.comment-new textarea {
|
||||
resize: vertical;
|
||||
max-height: 300px;
|
||||
min-height: 100px;
|
||||
max-height: 220px;
|
||||
min-height: 44px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
237
backend/static/js/components/chatHistoryLoader.js
Normal file
237
backend/static/js/components/chatHistoryLoader.js
Normal 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();
|
||||
}
|
||||
@ -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>`;
|
||||
|
||||
|
||||
@ -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 (индикация "печатает...")
|
||||
|
||||
@ -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;
|
||||
158
backend/static/scss/components/_message-inputs.scss
Normal file
158
backend/static/scss/components/_message-inputs.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -138,3 +138,6 @@ $badge-padding-x: 0.5rem !default; // var(--space-1)
|
||||
// 4. КАСТОМНЫЕ СТИЛИ (если нужно)
|
||||
// =============================================================================
|
||||
// Здесь можно добавить дополнительные стили или переопределения
|
||||
|
||||
@import "components/message-inputs";
|
||||
@import "components/chat-detail";
|
||||
|
||||
@ -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 %}
|
||||
|
||||
// Глобальный обновлятель бейджа в сайдбаре
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user