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

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