feat(chat): реализован рендеринг изображений и видео в чате

Проблема:
- Изображения и видео отображались только как ссылки с иконками
- Нет preview изображений в composer
- Вся инфраструктура существовала (DB поля, CSS, WebSocket), но не была подключена

Реализация:

messageRendererV2.js (_renderAttachment):
- Добавлена проверка file_type === 'image': рендеринг <img class="chat-media chat-media--image">
- Добавлена проверка file_type === 'video': рендеринг <video class="chat-media chat-media--video"> с controls
- Используется thumbnail URL для изображений (если доступен), иначе file_url
- Атрибуты: loading="lazy", playsinline, preload="metadata" для оптимизации
- Файлы (PDF, Word, Audio) рендерятся как ссылки с иконками

chatComposer.js (renderPreview):
- Для изображений: показываем thumbnail через URL.createObjectURL
- Для других файлов: иконка (как раньше)
- Добавлен URL.revokeObjectURL при удалении для предотвращения утечек памяти
- Preview размеры: 60x60px с border-radius и object-fit: cover

CSS (_chat-detail.scss):
- Все стили .chat-media уже были готовы:
  * max-width: min(640px, 85vw)
  * border-radius: 12px
  * object-fit: cover (изображения) / contain (видео)
  * Responsive sizing для мобильных
- Стили применяются автоматически при рендеринге

Технические детали:
- WebSocket уже передает: file_type, file_url, thumbnail, mime_type
- Database модель MessageAttachment поддерживает thumbnail поле
- CSS классы .chat-media--image и .chat-media--video определены
- Lazy loading для изображений (атрибут loading="lazy")
- Video с preload="metadata" для быстрой загрузки первого кадра

Тестирование: Требуется загрузка реальных изображений/видео для проверки
This commit is contained in:
Игорь Надеин 2026-01-14 11:22:15 +03:00
parent d86d971125
commit 759b7e8b0f
2 changed files with 71 additions and 17 deletions

View File

@ -629,22 +629,46 @@ class ChatComposer {
this.preview.innerHTML = '';
this.selectedFiles.forEach((entry) => {
const isImage = entry.file.type.startsWith('image/');
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>
`;
// Для изображений показываем превью
if (isImage) {
const imgUrl = URL.createObjectURL(entry.file);
wrapper.innerHTML = `
<img src="${imgUrl}" alt="${entry.file.name}"
style="max-width: 60px; max-height: 60px; border-radius: 6px; object-fit: cover;" />
<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>
`;
} else {
// Для других файлов - иконка
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);
// Очищаем URL.createObjectURL для изображений
if (isImage) {
const img = wrapper.querySelector('img');
if (img) URL.revokeObjectURL(img.src);
}
});
this.preview.appendChild(wrapper);

View File

@ -659,24 +659,54 @@ export class MessageRendererV2 {
const fileUrl = att.file_url || att.url || '#';
const fileSize = att.file_size || att.size || 0;
const fileType = att.file_type || att.type || '';
const thumbnailUrl = att.thumbnail || null;
// Форматируем размер
// Рендеринг изображений
if (fileType === 'image' || fileType.startsWith('image/')) {
const imgSrc = thumbnailUrl || fileUrl;
return `
<a href="${fileUrl}" target="_blank" class="attachment-item d-block">
<img src="${imgSrc}"
alt="${fileName}"
class="chat-media chat-media--image"
loading="lazy"
style="max-width: 100%; height: auto; display: block;" />
</a>
`;
}
// Рендеринг видео
if (fileType === 'video' || fileType.startsWith('video/')) {
return `
<div class="attachment-item">
<video src="${fileUrl}"
class="chat-media chat-media--video"
controls
playsinline
preload="metadata"
style="max-width: 100%; height: auto; display: block;">
Ваш браузер не поддерживает воспроизведение видео.
</video>
</div>
`;
}
// Форматируем размер для файлов
const sizeStr = fileSize > 0
? `${(fileSize / 1024).toFixed(1)} KB`
: '';
// Определяем иконку по типу
// Определяем иконку для файлов
let icon = 'bi-file-earmark';
if (fileType.startsWith('image/')) {
icon = 'bi-file-image';
} else if (fileType.startsWith('video/')) {
icon = 'bi-file-play';
} else if (fileType.includes('pdf')) {
if (fileType.includes('pdf')) {
icon = 'bi-file-pdf';
} else if (fileType.includes('word') || fileType.includes('document')) {
icon = 'bi-file-word';
} else if (fileType.startsWith('audio/')) {
icon = 'bi-file-music';
}
// Рендеринг обычных файлов (документы, аудио и т.д.)
return `
<a href="${fileUrl}"
target="_blank"