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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { Message } from "@/lib/api";
|
import type { Message, UploadedFile } from "@/lib/api";
|
||||||
import { getMessages, sendMessage } from "@/lib/api";
|
import { getMessages, sendMessage, uploadFile, getAttachmentUrl } from "@/lib/api";
|
||||||
import { wsClient } from "@/lib/ws";
|
import { wsClient } from "@/lib/ws";
|
||||||
import MentionInput from "./MentionInput";
|
import MentionInput from "./MentionInput";
|
||||||
|
|
||||||
@ -17,14 +17,27 @@ interface Props {
|
|||||||
projectSlug?: string;
|
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) {
|
export default function ChatPanel({ chatId, projectSlug }: Props) {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
|
const [pendingFiles, setPendingFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const isInitial = useRef(true);
|
const isInitial = useRef(true);
|
||||||
|
|
||||||
// Load initial messages
|
// Load initial messages
|
||||||
@ -96,13 +109,48 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder();
|
if (el && el.scrollTop < 50 && hasMore && !loadingOlder) loadOlder();
|
||||||
}, [loadOlder, hasMore, loadingOlder]);
|
}, [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 () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() || !chatId || sending) return;
|
if ((!input.trim() && pendingFiles.length === 0) || !chatId || sending) return;
|
||||||
const text = input.trim();
|
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("");
|
setInput("");
|
||||||
|
setPendingFiles([]);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
try {
|
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) => {
|
setMessages((prev) => {
|
||||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
return [...prev, msg];
|
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 renderMessage = (msg: Message) => {
|
||||||
const bgStyle =
|
const bgStyle =
|
||||||
msg.author_type === "system"
|
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>
|
<span>{new Date(msg.created_at).toLocaleString("ru-RU", { hour: "2-digit", minute: "2-digit" })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
<div className="ml-5 whitespace-pre-wrap">{renderContent(msg.content)}</div>
|
||||||
|
{renderAttachments(msg.attachments)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -166,7 +250,45 @@ export default function ChatPanel({ chatId, projectSlug }: Props) {
|
|||||||
)}
|
)}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</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">
|
<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
|
<MentionInput
|
||||||
projectSlug={projectSlug || ""}
|
projectSlug={projectSlug || ""}
|
||||||
value={input}
|
value={input}
|
||||||
|
|||||||
@ -261,6 +261,7 @@ export async function sendMessage(data: {
|
|||||||
task_id?: string;
|
task_id?: string;
|
||||||
content: string;
|
content: string;
|
||||||
mentions?: string[];
|
mentions?: string[];
|
||||||
|
attachments?: { file_id: string; filename: string; mime_type: string | null; size: number; storage_name: string }[];
|
||||||
}): Promise<Message> {
|
}): Promise<Message> {
|
||||||
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
|
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" });
|
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 ---
|
// --- Project Members ---
|
||||||
|
|
||||||
export async function getProjectMembers(slug: string): Promise<ProjectMember[]> {
|
export async function getProjectMembers(slug: string): Promise<ProjectMember[]> {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user