feat: file attachments in chat — upload, preview, download

This commit is contained in:
Markov 2026-02-25 06:17:17 +01:00
parent f4693e8f0b
commit 66beca5308
2 changed files with 159 additions and 5 deletions

View File

@ -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<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
const [uploading, setUploading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="ml-5 mt-1 flex flex-wrap gap-2">
{attachments.map((att) => {
const url = getAttachmentUrl(att.id);
if (isImageMime(att.mime_type)) {
return (
<a key={att.id} href={url} target="_blank" rel="noopener noreferrer">
<img
src={url}
alt={att.filename}
className="max-w-[300px] max-h-[200px] rounded border border-[var(--border)] object-cover"
/>
</a>
);
}
return (
<a
key={att.id}
href={url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 rounded border border-[var(--border)] bg-[var(--bg)] hover:bg-white/5 text-sm"
>
<span>📎</span>
<span className="truncate max-w-[200px]">{att.filename}</span>
<span className="text-[var(--muted)] text-xs">{formatFileSize(att.size)}</span>
</a>
);
})}
</div>
);
};
const renderMessage = (msg: Message) => {
const bgStyle =
msg.author_type === "system"
@ -143,6 +226,7 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
</div>
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
{renderAttachments(msg.attachments)}
</div>
);
};
@ -166,7 +250,45 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
)}
<div ref={bottomRef} />
</div>
{/* Pending files preview */}
{pendingFiles.length > 0 && (
<div className="px-6 py-2 border-t border-[var(--border)] flex flex-wrap gap-2">
{pendingFiles.map((f) => (
<div
key={f.file_id}
className="flex items-center gap-1 px-2 py-1 rounded bg-[var(--accent)]/10 text-xs"
>
<span>📎</span>
<span className="truncate max-w-[150px]">{f.filename}</span>
<span className="text-[var(--muted)]">{formatFileSize(f.size)}</span>
<button
onClick={() => removePendingFile(f.file_id)}
className="ml-1 text-[var(--muted)] hover:text-red-400"
>
</button>
</div>
))}
</div>
)}
<div className="px-6 py-3 border-t border-[var(--border)] flex gap-2 items-end">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileSelect}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-2 py-2 text-[var(--muted)] hover:text-[var(--fg)] transition-colors shrink-0 disabled:opacity-50"
title="Прикрепить файл"
>
{uploading ? "⏳" : "📎"}
</button>
<MentionInput
projectSlug={projectSlug || ""}
value={input}

View File

@ -261,6 +261,7 @@ export async function sendMessage(data: {
task_id?: string;
content: string;
mentions?: string[];
attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[];
}): Promise<Message> {
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
}
@ -301,6 +302,37 @@ export async function revokeToken(slug: string): Promise<void> {
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<UploadedFile> {
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<ProjectMember[]> {