feat: file attachments in chat — upload, preview, download
This commit is contained in:
parent
f4693e8f0b
commit
66beca5308
@ -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}
|
||||
|
||||
@ -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[]> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user