import { useEffect, useRef, useState } from "octane"; import { Button } from "octane-kumo/components/button"; import { ATTACHMENT_ACCEPT, MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES, MAX_IMAGE_BYTES, attachmentUrl, type Attachment, } from "../../shared/attachments"; export function AttachmentCards({ conversationId, files, onRemove, }: { conversationId: string; files: Attachment[]; onRemove?: (file: Attachment) => void; }) { return (
{files.map((file) => (
{file.mediaType.startsWith("image/") ? ( {file.name} ) : ( PDF )} {file.name} {(file.size / 1024).toFixed(0)} KiB {onRemove ? ( ) : null}
))}
); } export function AttachmentUpload({ conversationId, files, disabled, onChange, onBusy, }: { conversationId: string; files: Attachment[]; disabled: boolean; onChange: (files: Attachment[]) => void; onBusy: (busy: boolean) => void; }) { const [error, setError] = useState(""); const [progress, setProgress] = useState(null); const input = useRef(null); const lifetime = useRef(null); useEffect(() => { lifetime.current = new AbortController(); return () => lifetime.current?.abort(); }, []); const upload = async (selected: File[]) => { if (disabled || progress || !selected.length) return; if (selected.length + files.length > MAX_ATTACHMENTS) { setError("Attach up to four files per message."); return; } for (const file of selected) { if ( !ATTACHMENT_ACCEPT.split(",").includes(file.type) || !file.size || file.size > (file.type === "application/pdf" ? MAX_ATTACHMENT_BYTES : MAX_IMAGE_BYTES) ) { setError( "Choose PNG, JPEG or WebP up to 3.5 MiB, or PDF up to 20 MiB.", ); return; } } const signal = lifetime.current!.signal; setError(""); onBusy(true); const next = [...files]; try { for (const file of selected) { setProgress(`Uploading ${file.name}…`); const result = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); const abort = () => xhr.abort(); const cleanup = () => signal.removeEventListener("abort", abort); xhr.open("POST", `/api/conversations/${conversationId}/attachments`); xhr.setRequestHeader("X-Filename", encodeURIComponent(file.name)); xhr.setRequestHeader("Content-Type", file.type); xhr.upload.onprogress = (event) => { if (!signal.aborted && event.lengthComputable) setProgress( `Uploading ${file.name}: ${Math.round((event.loaded / event.total) * 100)}%`, ); }; xhr.onload = () => { cleanup(); if (xhr.status === 201) { try { resolve(JSON.parse(xhr.responseText)); } catch { reject(new Error("Invalid upload response")); } } else reject( new Error( xhr.status === 401 || xhr.status === 403 ? "Sign in again to upload files." : "Upload failed. Try again.", ), ); }; xhr.onerror = xhr.onabort = () => { cleanup(); reject(new Error("Upload interrupted. Try again.")); }; signal.addEventListener("abort", abort, { once: true }); if (signal.aborted) { cleanup(); reject(new Error("Upload cancelled")); return; } xhr.send(file); }); next.push(result); if (!signal.aborted) onChange([...next]); } } catch (error) { if (!signal.aborted) setError( error instanceof Error ? error.message : "Upload failed. Try again.", ); } finally { if (!signal.aborted) { setProgress(null); onBusy(false); } } }; return (
{ const selected = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ""; void upload(selected); }} /> {progress ?? "Images and PDFs · up to 4 files"} {error ?

{error}

: null}
); }