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:
2026-04-14 12:13:07 +03:00
parent d0dbf33701
commit 7ccf752429
5 changed files with 108 additions and 22 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT;

View File

@@ -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])

View File

@@ -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']

View File

@@ -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>
)}

View File

@@ -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 {