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 limit = Math.min(Number(request.query.limit ?? 50), 100)
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
`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(m.system_name, u.name) AS sender_name,
|
||||||
COALESCE(u.role, 'system') AS sender_role
|
COALESCE(u.role, 'system') AS sender_role
|
||||||
FROM chat_messages m
|
FROM chat_messages m
|
||||||
@@ -131,14 +131,14 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (roomRows[0]?.type === 'notifications')
|
if (roomRows[0]?.type === 'notifications')
|
||||||
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
return reply.code(403).send({ error: 'Cannot post to notifications room' })
|
||||||
|
|
||||||
const { text } = request.body
|
const { text, attachment_url } = request.body as { text?: string; attachment_url?: string }
|
||||||
if (!text?.trim()) return reply.code(400).send({ error: 'Text required' })
|
if (!text?.trim() && !attachment_url) return reply.code(400).send({ error: 'Text or attachment required' })
|
||||||
|
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text)
|
`INSERT INTO chat_messages (room_id, hotel_id, sender_id, text, attachment_url)
|
||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
RETURNING id, room_id, sender_id, text, created_at`,
|
RETURNING id, room_id, sender_id, text, created_at, attachment_url`,
|
||||||
[roomId, hotelId, request.user.sub, text.trim()],
|
[roomId, hotelId, request.user.sub, text?.trim() ?? '', attachment_url ?? null],
|
||||||
)
|
)
|
||||||
const msg = rows[0]
|
const msg = rows[0]
|
||||||
const { rows: uRows } = await db.query('SELECT name, role FROM users WHERE id = $1', [request.user.sub])
|
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 UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
|
||||||
const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru'
|
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]
|
type UploadFolder = typeof ALLOWED_FOLDERS[number]
|
||||||
|
|
||||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
MessageSquare, X, ChevronLeft, Send, Users, Loader2,
|
||||||
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX,
|
Search, Bell, Settings, PenSquare, BellOff, Volume2, VolumeX, Paperclip,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api'
|
import { api, type ChatRoom, type ChatMessage, type ChatSearchResult } from '../../lib/api'
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
@@ -85,6 +85,8 @@ export function ChatWidget() {
|
|||||||
const [loadingRooms, setLoadingRooms] = useState(false)
|
const [loadingRooms, setLoadingRooms] = useState(false)
|
||||||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
const [attachment, setAttachment] = useState<File | null>(null)
|
||||||
|
const [attachPreview, setAttachPreview] = useState<string | null>(null)
|
||||||
|
|
||||||
// Search state
|
// Search state
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
@@ -100,6 +102,7 @@ export function ChatWidget() {
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const totalUnread = rooms.reduce((s, r) => s + (Number(r.unreadCount) || 0), 0)
|
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 () => {
|
const sendMessage = async () => {
|
||||||
if (!text.trim() || !activeRoom || sending) return
|
if (!text.trim() && !attachment || !activeRoom || sending) return
|
||||||
const t = text.trim()
|
const t = text.trim()
|
||||||
|
const file = attachment
|
||||||
setText('')
|
setText('')
|
||||||
|
setAttachment(null)
|
||||||
|
setAttachPreview(null)
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
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])
|
setMessages(prev => [...prev, msg])
|
||||||
} catch {
|
} catch {
|
||||||
setText(t)
|
setText(t)
|
||||||
|
if (file) { setAttachment(file); setAttachPreview(attachPreview) }
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -251,7 +278,7 @@ export function ChatWidget() {
|
|||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void sendMessage()
|
if (text.trim() || attachment) void sendMessage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,14 +587,31 @@ export function ChatWidget() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'px-3 py-2 rounded-2xl text-sm',
|
'rounded-2xl text-sm overflow-hidden',
|
||||||
isSystem
|
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
|
: isOwn
|
||||||
? 'bg-brand-600 text-white rounded-tr-sm'
|
? 'bg-brand-600 rounded-tr-sm'
|
||||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 rounded-tl-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>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
|
<p className="text-[10px] text-slate-400 mt-0.5 mx-1">{fmtTime(msg.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +625,34 @@ export function ChatWidget() {
|
|||||||
{/* Input — hidden for notifications room */}
|
{/* Input — hidden for notifications room */}
|
||||||
{activeRoom?.type !== 'notifications' && (
|
{activeRoom?.type !== 'notifications' && (
|
||||||
<div className="px-3 py-3 border-t border-slate-200 dark:border-slate-700 shrink-0">
|
<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
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={e => setText(e.target.value)}
|
||||||
@@ -597,13 +668,13 @@ export function ChatWidget() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => void sendMessage()}
|
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"
|
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} />}
|
{sending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -335,8 +335,21 @@ export const api = {
|
|||||||
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
||||||
getMessages: (slug: string, roomId: string, limit = 50) =>
|
getMessages: (slug: string, roomId: string, limit = 50) =>
|
||||||
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
|
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
|
||||||
sendMessage: (slug: string, roomId: string, text: string) =>
|
sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
|
||||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text }),
|
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) =>
|
markRead: (slug: string, roomId: string) =>
|
||||||
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/chat/rooms/${roomId}/read`),
|
||||||
openDirect: (slug: string, otherUserId: string) =>
|
openDirect: (slug: string, otherUserId: string) =>
|
||||||
@@ -1326,6 +1339,7 @@ export interface ChatMessage {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
isSystem: boolean
|
isSystem: boolean
|
||||||
systemName: string | null
|
systemName: string | null
|
||||||
|
attachmentUrl: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatSearchResult {
|
export interface ChatSearchResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user