diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 89ba113..fd43c41 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { Message } from "@/lib/api"; -import { getMessages, sendMessage } from "@/lib/api"; +import type { Message, UploadedFile } from "@/lib/api"; +import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api"; import { wsClient } from "@/lib/ws"; import MentionInput from "./MentionInput"; @@ -17,14 +17,27 @@ interface Props { projectSlug?: string; } +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1024 / 1024).toFixed(1)} MB`; +} + +function isImageMime(mime: string | null): boolean { + return !!mime && mime.startsWith("image/"); +} + export default function ChatPanel({ chatId, projectSlug }: Props) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [hasMore, setHasMore] = useState(true); + const [pendingFiles, setPendingFiles] = useState([]); + const [uploading, setUploading] = useState(false); const scrollRef = useRef(null); const bottomRef = useRef(null); + const fileInputRef = useRef(null); const isInitial = useRef(true); // Load initial messages @@ -96,13 +109,48 @@ export default function ChatPanel({ chatId, projectSlug }: Props) { if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder(); }, [loadOlder, hasMore, loadingOlder]); + const handleFileSelect = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files?.length) return; + setUploading(true); + try { + const uploaded: UploadedFile[] = []; + for (const file of Array.from(files)) { + const result = await uploadFile(file); + uploaded.push(result); + } + setPendingFiles((prev) => [...prev, ...uploaded]); + } catch (err) { + console.error("Upload failed:", err); + } finally { + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + }; + + const removePendingFile = (fileId: string) => { + setPendingFiles((prev) => prev.filter((f) => f.file_id !== fileId)); + }; + const handleSend = async () => { - if (!input.trim() || !chatId || sending) return; - const text = input.trim(); + if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return; + const text = input.trim() || (pendingFiles.length > 0 ? `๐Ÿ“Ž ${pendingFiles.map(f => f.filename).join(", ")}` : ""); + const attachments = pendingFiles.map((f) => ({ + file_id: f.file_id, + filename: f.filename, + mime_type: f.mime_type, + size: f.size, + storage_name: f.storage_name, + })); setInput(""); + setPendingFiles([]); setSending(true); try { - const msg = await sendMessage({ chat_id: chatId, content: text }); + const msg = await sendMessage({ + chat_id: chatId, + content: text, + attachments: attachments.length > 0 ? attachments : undefined, + }); setMessages((prev) => { if (prev.some((m) => m.id === msg.id)) return prev; return [...prev, msg]; @@ -127,6 +175,41 @@ export default function ChatPanel({ chatId, projectSlug }: Props) { ); }; + const renderAttachments = (attachments: Message["attachments"]) => { + if (!attachments?.length) return null; + return ( +
+ {attachments.map((att) => { + const url = getAttachmentUrl(att.id); + if (isImageMime(att.mime_type)) { + return ( + + {att.filename} + + ); + } + return ( + + ๐Ÿ“Ž + {att.filename} + {formatFileSize(att.size)} + + ); + })} +
+ ); + }; + const renderMessage = (msg: Message) => { const bgStyle = msg.author_type === "system" @@ -143,6 +226,7 @@ export default function ChatPanel({ chatId, projectSlug }: Props) { {new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}
{renderContent(msg.content)}
+ {renderAttachments(msg.attachments)} ); }; @@ -166,7 +250,45 @@ export default function ChatPanel({ chatId, projectSlug }: Props) { )}
+ + {/* Pending files preview */} + {pendingFiles.length > 0 && ( +
+ {pendingFiles.map((f) => ( +
+ ๐Ÿ“Ž + {f.filename} + {formatFileSize(f.size)} + +
+ ))} +
+ )} +
+ + { return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) }); } @@ -301,6 +302,37 @@ export async function revokeToken(slug: string): Promise { await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" }); } +// --- File Upload --- + +export interface UploadedFile { + file_id: string; + filename: string; + mime_type: string | null; + size: number; + storage_name: string; +} + +export async function uploadFile(file: File): Promise { + const token = getToken(); + const formData = new FormData(); + formData.append("file", file); + + const res = await fetch(`${API_BASE}/api/v1/upload`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || `HTTP ${res.status}`); + } + return res.json(); +} + +export function getAttachmentUrl(attachmentId: string): string { + return `${API_BASE}/api/v1/attachments/${attachmentId}/download`; +} + // --- Project Members --- export async function getProjectMembers(slug: string): Promise {