feat: chat — photo attachments
- Migration 073: attachment_url column in chat_messages - upload.ts: add 'chat' folder to ALLOWED_FOLDERS - chat.ts: accept attachment_url in send message, return it in GET messages - api.ts: attachmentUrl in ChatMessage type, uploadImage() with raw FormData fetch - ChatWidget: paperclip button, file preview thumbnail with remove, image display inline in message bubbles (clickable, opens original), send enabled with image only Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
backend/migrations/073_chat_attachments.sql
Normal file
1
backend/migrations/073_chat_attachments.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT;
|
||||
@@ -94,7 +94,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
const limit = Math.min(Number(request.query.limit ?? 50), 100)
|
||||
const { rows } = await db.query(
|
||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
||||
m.is_system, m.system_name,
|
||||
m.is_system, m.system_name, m.attachment_url,
|
||||
COALESCE(m.system_name, u.name) AS sender_name,
|
||||
COALESCE(u.role, 'system') AS sender_role
|
||||
FROM chat_messages m
|
||||
@@ -131,14 +131,14 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
if (roomRows[0]?.type === 'notifications')
|
||||
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
||||
|
||||
const { text } = request.body
|
||||
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
|
||||
const { text, attachment_url } = request.body as { text?: string; attachment_url?: string }
|
||||
if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' })
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, room_id, sender_id, text, created_at`,
|
||||
[roomId, hotelId, request.user.sub, text.trim()],
|
||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, room_id, sender_id, text, created_at, attachment_url`,
|
||||
[roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null],
|
||||
)
|
||||
const msg = rows[0]
|
||||
const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
|
||||
|
||||
@@ -7,7 +7,7 @@ import { pipeline } from 'stream/promises'
|
||||
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
|
||||
const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru'
|
||||
|
||||
const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks'] as const
|
||||
const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks', 'chat'] as const
|
||||
type UploadFolder = typeof ALLOWED_FOLDERS[number]
|
||||
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
||||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX,
|
||||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip,
|
||||
} from 'lucide-react'
|
||||
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api'
|
||||
import type { User } from '../../types'
|
||||
@@ -85,6 +85,8 @@ export function ChatWidget() {
|
||||
const [loadingRooms, setLoadingRooms] = useState(false)
|
||||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [attachment, setAttachment] = useState<File | null>(null)
|
||||
const [attachPreview, setAttachPreview] = useState<string | null>(null)
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
@@ -100,6 +102,7 @@ export function ChatWidget() {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
||||
|
||||
@@ -233,16 +236,40 @@ export function ChatWidget() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setAttachment(file)
|
||||
const reader = new FileReader()
|
||||
reader.onload = ev => setAttachPreview(ev.target?.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const removeAttachment = () => {
|
||||
setAttachment(null)
|
||||
setAttachPreview(null)
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!text.trim() || !activeRoom || sending) return
|
||||
if (!text.trim() && !attachment || !activeRoom || sending) return
|
||||
const t = text.trim()
|
||||
const file = attachment
|
||||
setText('')
|
||||
setAttachment(null)
|
||||
setAttachPreview(null)
|
||||
setSending(true)
|
||||
try {
|
||||
const msg = await api.chat.sendMessage(slug, activeRoom.id, t)
|
||||
let attachmentUrl: string | undefined
|
||||
if (file) {
|
||||
const { url } = await api.chat.uploadImage(file)
|
||||
attachmentUrl = url
|
||||
}
|
||||
const msg = await api.chat.sendMessage(slug, activeRoom.id, t, attachmentUrl)
|
||||
setMessages(prev => [...prev, msg])
|
||||
} catch {
|
||||
setText(t)
|
||||
if (file) { setAttachment(file); setAttachPreview(attachPreview) }
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -251,7 +278,7 @@ export function ChatWidget() {
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
void sendMessage()
|
||||
if (text.trim() || attachment) void sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -560,14 +587,31 @@ export function ChatWidget() {
|
||||
</p>
|
||||
)}
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-2xl text-sm',
|
||||
'rounded-2xl text-sm overflow-hidden',
|
||||
isSystem
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-900 dark:text-amber-200 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
|
||||
? 'bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-tl-sm'
|
||||
: isOwn
|
||||
? 'bg-brand-600 text-white rounded-tr-sm'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-sm',
|
||||
? 'bg-brand-600 rounded-tr-sm'
|
||||
: 'bg-slate-100 dark:bg-slate-700 rounded-tl-sm',
|
||||
)}>
|
||||
{msg.text}
|
||||
{msg.attachmentUrl && (
|
||||
<a href={msg.attachmentUrl} target="_blank" rel="noreferrer">
|
||||
<img
|
||||
src={msg.attachmentUrl}
|
||||
alt="вложение"
|
||||
className="max-w-full rounded-t-2xl block"
|
||||
style={{ maxHeight: 180, objectFit: 'cover', width: '100%' }}
|
||||
/>
|
||||
</a>
|
||||
)}
|
||||
{msg.text && (
|
||||
<p className={cn(
|
||||
'px-3 py-2',
|
||||
isSystem
|
||||
? 'text-amber-900 dark:text-amber-200'
|
||||
: isOwn ? 'text-white' : 'text-slate-800 dark:text-slate-200',
|
||||
)}>{msg.text}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
|
||||
</div>
|
||||
@@ -581,7 +625,34 @@ export function ChatWidget() {
|
||||
{/* Input — hidden for notifications room */}
|
||||
{activeRoom?.type !== 'notifications' && (
|
||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attachment preview */}
|
||||
{attachPreview && (
|
||||
<div className="relative inline-block mb-2">
|
||||
<img src={attachPreview} alt="превью" className="h-16 rounded-lg object-cover border border-slate-200 dark:border-slate-600" />
|
||||
<button
|
||||
onClick={removeAttachment}
|
||||
className="absolute -top-1.5 -right-1.5 w-5 h-5 rounded-full bg-slate-700 text-white flex items-center justify-center hover:bg-red-500 transition-colors"
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-1.5">
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Прикрепить фото"
|
||||
className="p-2 rounded-xl text-slate-400 hover:text-brand-600 hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors shrink-0"
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
@@ -597,13 +668,13 @@ export function ChatWidget() {
|
||||
/>
|
||||
<button
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={!text.trim() || sending}
|
||||
disabled={(!text.trim() && !attachment) || sending}
|
||||
className="p-2.5 rounded-xl bg-brand-600 hover:bg-brand-700 disabled:opacity-40 text-white transition-colors shrink-0"
|
||||
>
|
||||
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить, Shift+Enter — перенос</p>
|
||||
<p className="text-[10px] text-slate-400 mt-1.5">Enter — отправить · Shift+Enter — перенос</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -335,8 +335,21 @@ export const api = {
|
||||
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
||||
getMessages: (slug: string, roomId: string, limit = 50) =>
|
||||
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
|
||||
sendMessage: (slug: string, roomId: string, text: string) =>
|
||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text }),
|
||||
sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
|
||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }),
|
||||
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const token = getToken()
|
||||
const res = await fetch(`${BASE}/api/upload?folder=chat`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
credentials: 'include',
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) throw new ApiError(res.status, 'Upload failed')
|
||||
return res.json() as Promise<{ url: string }>
|
||||
},
|
||||
markRead: (slug: string, roomId: string) =>
|
||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||||
openDirect: (slug: string, otherUserId: string) =>
|
||||
@@ -1326,6 +1339,7 @@ export interface ChatMessage {
|
||||
createdAt: string
|
||||
isSystem: boolean
|
||||
systemName: string | null
|
||||
attachmentUrl: string | null
|
||||
}
|
||||
|
||||
export interface ChatSearchResult {
|
||||
|
||||
Reference in New Issue
Block a user