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,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