Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
5.7 kB · 192 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193import { 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 ( <div className="chat-attachments"> {files.map((file) => ( <div key={file.id} className="chat-attachment"> {file.mediaType.startsWith("image/") ? ( <img src={attachmentUrl(conversationId, file.id)} alt={file.name} loading="lazy" /> ) : ( <span className="muted">PDF</span> )} <a href={attachmentUrl(conversationId, file.id) + "?download"} download={file.name} > {file.name} </a> <span className="muted">{(file.size / 1024).toFixed(0)} KiB</span> {onRemove ? ( <Button size="sm" variant="secondary" aria-label={`Remove ${file.name}`} onClick={() => onRemove(file)} > Remove </Button> ) : null} </div> ))} </div> );}
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<string | null>(null); const input = useRef<HTMLInputElement | null>(null); const lifetime = useRef<AbortController | null>(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<Attachment>((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 ( <div className="chat-upload"> <input ref={input} type="file" aria-label="Upload images or PDFs" accept={ATTACHMENT_ACCEPT} multiple hidden onChange={(event) => { const selected = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ""; void upload(selected); }} /> <Button size="sm" variant="secondary" disabled={disabled || !!progress || files.length >= MAX_ATTACHMENTS} onClick={() => input.current?.click()} > Attach files </Button> <span className="muted" role="status"> {progress ?? "Images and PDFs · up to 4 files"} </span> {error ? <p role="alert">{error}</p> : null} </div> );}