| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useEffect, useRef, useState } from "react"; |
| import { MAX_DOC_BYTES } from "./types"; |
| import type { CustomerDoc } from "./types"; |
|
|
| function sizeText(bytes: number): string { |
| if (!Number.isFinite(bytes) || bytes < 0) return "—"; |
| if (bytes < 1024) return `${bytes} B`; |
| if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; |
| return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; |
| } |
|
|
| |
| |
| function byLine(doc: CustomerDoc): string { |
| const parts: string[] = []; |
| if (doc.by) parts.push(doc.by); |
| if (doc.ts) parts.push(doc.ts.slice(0, 16).replace("T", " ")); |
| return parts.join(" · "); |
| } |
|
|
| export function Documents({ |
| pid, |
| docs, |
| docPayload, |
| onAdd, |
| onFetch, |
| onDelete, |
| }: { |
| pid: number; |
| docs: CustomerDoc[]; |
| /** The host's answer to the most recent `doc_fetch`, if one has arrived. */ |
| docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string }; |
| onAdd: (file: { name: string; mime: string; size: number; data_b64: string }) => void; |
| onFetch: (docId: string) => void; |
| onDelete: (docId: string) => void; |
| }) { |
| const fileRef = useRef<HTMLInputElement>(null); |
| const [error, setError] = useState<string | null>(null); |
| const [busy, setBusy] = useState(false); |
| /** What we asked the host for, so an arriving docPayload is only consumed when |
| * it is the answer to OUR request (and only once). */ |
| const wantRef = useRef<string | null>(null); |
| const [confirmId, setConfirmId] = useState<string | null>(null); |
| |
| // The download half: recognise our answer, save it, forget it. |
| useEffect(() => { |
| if (!docPayload || wantRef.current == null) return; |
| if (docPayload.pid !== pid || docPayload.docId !== wantRef.current) return; |
| wantRef.current = null; |
| try { |
| const bin = atob(docPayload.data_b64); |
| const bytes = new Uint8Array(bin.length); |
| for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i); |
| const url = URL.createObjectURL( |
| new Blob([bytes], { type: docPayload.mime || "application/octet-stream" }) |
| ); |
| const a = document.createElement("a"); |
| a.href = url; |
| a.download = docPayload.name || "document"; |
| document.body.appendChild(a); |
| a.click(); |
| a.remove(); |
| // Revoked on the next tick: revoking synchronously can beat the click in |
| // some browsers and the download silently does nothing. |
| window.setTimeout(() => URL.revokeObjectURL(url), 0); |
| } catch { |
| setError("That file could not be decoded. Nothing was downloaded."); |
| } |
| }, [docPayload, pid]); |
| |
| const pick = (file: File | undefined) => { |
| setError(null); |
| if (!file) return; |
| // Checked on file.size, BEFORE reading — see the header note. |
| if (file.size > MAX_DOC_BYTES) { |
| setError( |
| `${file.name} is ${sizeText(file.size)}. The limit is ${sizeText(MAX_DOC_BYTES)}, so it was not uploaded.` |
| ); |
| if (fileRef.current) fileRef.current.value = ""; |
| return; |
| } |
| setBusy(true); |
| const reader = new FileReader(); |
| reader.onerror = () => { |
| setBusy(false); |
| setError(`${file.name} could not be read.`); |
| }; |
| reader.onload = () => { |
| setBusy(false); |
| const result = String(reader.result ?? ""); |
| const comma = result.indexOf(","); |
| if (comma < 0) { |
| setError(`${file.name} could not be encoded.`); |
| return; |
| } |
| onAdd({ |
| name: file.name, |
| mime: file.type || "application/octet-stream", |
| size: file.size, |
| data_b64: result.slice(comma + 1), |
| }); |
| if (fileRef.current) fileRef.current.value = ""; |
| }; |
| reader.readAsDataURL(file); |
| }; |
| |
| return ( |
| <section className="cg-detail-section cg-docs"> |
| <div className="cg-detail-section-title">Documents</div> |
| |
| {docs.length === 0 ? ( |
| <div className="cg-docs-empty">No documents on this customer yet.</div> |
| ) : ( |
| <ul className="cg-docs-list"> |
| {docs.map((d) => ( |
| <li key={d.id} className="cg-docs-row"> |
| <button |
| type="button" |
| className="cg-link-btn cg-docs-name" |
| title={`Download ${d.name}`} |
| onClick={() => { |
| setError(null); |
| wantRef.current = d.id; |
| onFetch(d.id); |
| }} |
| > |
| {d.name} |
| </button> |
| <span className="cg-docs-meta"> |
| {sizeText(d.size)} |
| {byLine(d) ? ` · ${byLine(d)}` : ""} |
| </span> |
| {d.canDelete !== false && ( |
| <button |
| type="button" |
| className={"cg-docs-del" + (confirmId === d.id ? " is-armed" : "")} |
| aria-label={confirmId === d.id ? `Delete ${d.name}?` : `Delete ${d.name}`} |
| onClick={() => { |
| if (confirmId !== d.id) { |
| setConfirmId(d.id); |
| return; |
| } |
| setConfirmId(null); |
| onDelete(d.id); |
| }} |
| > |
| {confirmId === d.id ? "Delete?" : "×"} |
| </button> |
| )} |
| </li> |
| ))} |
| </ul> |
| )} |
| |
| <div className="cg-docs-add"> |
| <input |
| ref={fileRef} |
| type="file" |
| className="cg-docs-file" |
| aria-label="Add a document" |
| disabled={busy} |
| onChange={(e) => pick(e.target.files?.[0])} |
| /> |
| <span className="cg-docs-cap">Up to {sizeText(MAX_DOC_BYTES)} per file.</span> |
| </div> |
| {error && <div className="cg-docs-error">{error}</div>} |
| </section> |
| ); |
| } |
| |