From 55367499858f904f3e314428bd1e3242bc41b1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 7 Jul 2026 12:25:45 +0200 Subject: [PATCH] feat(documents): client upload queue with progress and duplicate handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One multipart XHR POST per file (real upload-progress events, no new dependency), concurrency capped at 3 to match the server's memory envelope, an Idempotency-Key per file so a retried blip cannot double-store, and a canvas downscale to 3000 px longest edge for raster photos only — PDFs, Office files, and GIFs upload verbatim. Entries appear synchronously before any network work; an over-limit file fails locally against the configured cap while the batch continues; a duplicate resolves to the existing row and hands its id to the timeline for a brief highlight. Plan-Task: W2-T4 (manager half) --- .../documents/use-document-upload.ts | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 src/components/documents/use-document-upload.ts diff --git a/src/components/documents/use-document-upload.ts b/src/components/documents/use-document-upload.ts new file mode 100644 index 000000000..8f7977a29 --- /dev/null +++ b/src/components/documents/use-document-upload.ts @@ -0,0 +1,282 @@ +"use client"; + +/** + * The vault's upload manager: a client-side queue with concurrency 3, one + * multipart XHR POST per file (XHR because it exposes real upload-progress + * events with zero dependencies — fetch upload streaming is not portable), + * client-side image downscaling, and per-file terminal states. + * + * Perceived-speed contract: `enqueue()` adds pending entries synchronously + * (< 100 ms — before any network work), each carrying live progress. A + * finished file leaves the queue and the list/usage queries refetch; a + * failed file stays as an inline error card until dismissed (an over-limit + * file fails individually, the batch continues). + * + * Duplicate contract (§3.2): a same-user re-upload returns HTTP 200 with + * envelope-level `meta.duplicate: true` and the EXISTING row — the manager + * toasts "already stored" and hands the row id to the timeline for a brief + * highlight. This is the one call site that must read success-side `meta`; + * `parseUploadResponse` in `vault-utils.ts` owns that parse. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; + +import { useTranslations } from "@/lib/i18n/context"; +import { invalidateKeys, queryKeys } from "@/lib/query-keys"; +import { + parseUploadResponse, + type UploadFailure, + type UploadResult, +} from "./vault-utils"; + +/** Client-side parallelism — matches the plan's NAS memory envelope. */ +const CONCURRENCY = 3; + +/** Longest-edge ceiling for the client-side image downscale. */ +const IMAGE_MAX_EDGE = 3000; + +/** How long a duplicate-highlight ring stays on the existing row. */ +const HIGHLIGHT_MS = 2600; + +/** Only raster photo formats are re-encoded — never PDFs/Office, never GIF + * (re-encoding would flatten an animation and GIF photos don't exist). */ +const DOWNSCALE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]); + +export interface UploadQueueItem { + /** Local id — NOT a server id; stable for the item's queue lifetime. */ + localId: string; + fileName: string; + byteSize: number; + status: "uploading" | "error"; + /** 0..1 upload progress (transport bytes, not server processing). */ + progress: number; + /** Terminal failure detail, present when `status === "error"`. */ + failure?: UploadFailure; +} + +export interface EnqueueOptions { + /** Pre-link every uploaded document to this episode (deep-link uploads). */ + episodeId?: string; +} + +interface UploadLimits { + maxFileBytes: number; +} + +/** + * Downscale a raster photo to ≤ 3000 px longest edge (canvas, JPEG 0.85). + * Returns the original file untouched when the type is not a raster photo, + * the image is already small enough, or anything about decoding fails — + * a downscale must never block an upload. + */ +async function downscaleImage(file: File): Promise { + if (!DOWNSCALE_TYPES.has(file.type)) return file; + try { + const bitmap = await createImageBitmap(file); + const longest = Math.max(bitmap.width, bitmap.height); + if (longest <= IMAGE_MAX_EDGE) { + bitmap.close(); + return file; + } + const scale = IMAGE_MAX_EDGE / longest; + const width = Math.round(bitmap.width * scale); + const height = Math.round(bitmap.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + bitmap.close(); + return file; + } + ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + const blob = await new Promise((resolve) => + canvas.toBlob(resolve, "image/jpeg", 0.85), + ); + if (!blob) return file; + const stem = file.name.replace(/\.[^.]+$/u, "") || "photo"; + return new File([blob], `${stem}.jpg`, { type: "image/jpeg" }); + } catch { + return file; + } +} + +/** One multipart POST via XHR, reporting upload progress. */ +function uploadViaXhr( + file: File, + options: EnqueueOptions, + onProgress: (fraction: number) => void, +): Promise { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", "/api/documents/inbound"); + // Retry-safe by construction: the server honours Idempotency-Key, so a + // network blip that DID land server-side cannot double-store on retry. + xhr.setRequestHeader("Idempotency-Key", crypto.randomUUID()); + xhr.upload.addEventListener("progress", (event) => { + if (event.lengthComputable && event.total > 0) { + onProgress(event.loaded / event.total); + } + }); + xhr.addEventListener("load", () => { + resolve(parseUploadResponse(xhr.status, xhr.responseText)); + }); + xhr.addEventListener("error", () => + resolve({ ok: false, reason: "generic" }), + ); + xhr.addEventListener("abort", () => + resolve({ ok: false, reason: "generic" }), + ); + const fd = new FormData(); + fd.append("file", file); + if (options.episodeId) fd.append("episodeIds", options.episodeId); + xhr.send(fd); + }); +} + +export interface DocumentUploadManager { + /** Live queue — uploading entries first-in-first, then error entries. */ + items: UploadQueueItem[]; + /** Add files to the queue; returns immediately (optimistic entries). */ + enqueue: (files: File[], options?: EnqueueOptions) => void; + /** Remove a terminal (error) entry from the queue. */ + dismiss: (localId: string) => void; + /** Row id to briefly highlight (duplicate upload → the existing row). */ + highlightId: string | null; +} + +export function useDocumentUpload( + limits: UploadLimits | undefined, +): DocumentUploadManager { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [items, setItems] = useState([]); + const [highlightId, setHighlightId] = useState(null); + + const activeRef = useRef(0); + const pendingRef = useRef< + { localId: string; file: File; options: EnqueueOptions }[] + >([]); + const mountedRef = useRef(true); + const highlightTimer = useRef | null>(null); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (highlightTimer.current) clearTimeout(highlightTimer.current); + }; + }, []); + + const patchItem = useCallback( + (localId: string, patch: Partial) => { + if (!mountedRef.current) return; + setItems((prev) => + prev.map((it) => (it.localId === localId ? { ...it, ...patch } : it)), + ); + }, + [], + ); + + const removeItem = useCallback((localId: string) => { + if (!mountedRef.current) return; + setItems((prev) => prev.filter((it) => it.localId !== localId)); + }, []); + + const flashHighlight = useCallback((id: string) => { + if (!mountedRef.current) return; + setHighlightId(id); + if (highlightTimer.current) clearTimeout(highlightTimer.current); + highlightTimer.current = setTimeout(() => { + if (mountedRef.current) setHighlightId(null); + }, HIGHLIGHT_MS); + }, []); + + const settle = useCallback( + (localId: string, result: UploadResult) => { + if (result.ok) { + removeItem(localId); + void invalidateKeys(queryClient, [queryKeys.documents()]); + if (result.duplicate) { + toast.info(t("documents.toast.duplicate")); + flashHighlight(result.document.id); + } + return; + } + patchItem(localId, { status: "error", failure: result, progress: 0 }); + }, + [flashHighlight, patchItem, queryClient, removeItem, t], + ); + + const pump = useCallback(() => { + while ( + activeRef.current < CONCURRENCY && + pendingRef.current.length > 0 && + mountedRef.current + ) { + const next = pendingRef.current.shift(); + if (!next) return; + activeRef.current += 1; + void (async () => { + try { + const prepared = await downscaleImage(next.file); + const result = await uploadViaXhr(prepared, next.options, (f) => + patchItem(next.localId, { progress: f }), + ); + settle(next.localId, result); + } finally { + activeRef.current -= 1; + pump(); + } + })(); + } + }, [patchItem, settle]); + + const enqueue = useCallback( + (files: File[], options: EnqueueOptions = {}) => { + if (files.length === 0) return; + const fresh: UploadQueueItem[] = []; + for (const file of files) { + const localId = crypto.randomUUID(); + // Client pre-flight against the server's configured cap: an obvious + // giant (a CD image) fails instantly and locally — friendly reason, + // no wasted transfer. The server re-enforces regardless. + if (limits && file.size > limits.maxFileBytes) { + fresh.push({ + localId, + fileName: file.name, + byteSize: file.size, + status: "error", + progress: 0, + failure: { + ok: false, + reason: "fileTooLarge", + maxFileBytes: limits.maxFileBytes, + }, + }); + continue; + } + fresh.push({ + localId, + fileName: file.name, + byteSize: file.size, + status: "uploading", + progress: 0, + }); + pendingRef.current.push({ localId, file, options }); + } + setItems((prev) => [...prev, ...fresh]); + pump(); + }, + [limits, pump], + ); + + const dismiss = useCallback( + (localId: string) => removeItem(localId), + [removeItem], + ); + + return { items, enqueue, dismiss, highlightId }; +}