import { useEffect, useMemo, useRef, useState } from "react"; import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader"; import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; import type { MediaFile } from "@/domain/work-orders/types/work-order-media"; import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail"; import { classifyUpload } from "@/domain/work-orders/utils/work-order-media-utils"; type LocalUpload = MediaFile & { sourceFile?: File; signal?: AbortSignal }; type UploadMutate = (input: { file: File; signal?: AbortSignal; onProgress?: (percent: number) => void; }) => Promise; type SessionUploadRequest = { localId: string; file: File; signal: AbortSignal; }; function revokeBlobUrl(url: string | undefined) { if (url?.startsWith("blob:")) { URL.revokeObjectURL(url); } } function toLocalUpload(request: SessionUploadRequest): LocalUpload { const classified = classifyUpload(request.file); return { id: request.localId, name: request.file.name, fileType: classified.fileType, url: URL.createObjectURL(request.file), category: classified.category, purpose: null, progress: 0, uploadStatus: "uploading" as const, sourceFile: request.file, signal: request.signal, }; } function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) { return item.id === request.localId && item.signal === request.signal; } function upsertUploads(previous: LocalUpload[], entries: LocalUpload[]): LocalUpload[] { const next = [...previous]; for (const entry of entries) { const index = next.findIndex((item) => item.id === entry.id); if (index >= 0) { revokeBlobUrl(next[index]?.url); next[index] = entry; } else { next.push(entry); } } return next; } function dropUploads( previous: LocalUpload[], matches: (item: LocalUpload) => boolean, ): LocalUpload[] { const kept: LocalUpload[] = []; for (const item of previous) { if (matches(item)) { revokeBlobUrl(item.url); continue; } kept.push(item); } return kept; } export function useMediaUploadSession( detail: WorkOrderBoardDetail | undefined, uploadMutateAsync: UploadMutate, ) { const [localUploads, setLocalUploads] = useState([]); const controllersRef = useRef(new Map()); const workOrderId = detail?.info.id; const previousWorkOrderIdRef = useRef(workOrderId); // Drop local tiles and abort in-flight uploads when the bound WO changes. // Skip the first bind (undefined → id) so an early upload is not cleared. useEffect(() => { if (previousWorkOrderIdRef.current === workOrderId) return; const hadPrevious = previousWorkOrderIdRef.current != null; previousWorkOrderIdRef.current = workOrderId; if (!hadPrevious) return; for (const controller of controllersRef.current.values()) { controller.abort(); } controllersRef.current.clear(); setLocalUploads((prev) => dropUploads(prev, () => true)); }, [workOrderId]); const serverUploads = useMemo( () => boardMediaItemsToMediaFiles(detail?.media ?? []), [detail?.media], ); const uploads = useMemo(() => { const serverIds = new Set(serverUploads.map((item) => item.id)); const pendingLocals = localUploads.filter( (item) => item.uploadStatus === "uploading" || item.uploadStatus === "failed", ); return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))]; }, [serverUploads, localUploads]); function clearController(localId: string) { controllersRef.current.delete(localId); } function replaceController(localId: string): AbortController { controllersRef.current.get(localId)?.abort(); const controller = new AbortController(); controllersRef.current.set(localId, controller); return controller; } function settleAttempt(request: SessionUploadRequest) { clearController(request.localId); setLocalUploads((prev) => dropUploads(prev, (item) => isSameAttempt(item, request))); } function failAttempt(request: SessionUploadRequest, error: Error) { // Keep the controller entry while the attempt is retryable; retry replaces it. setLocalUploads((prev) => prev.map((item) => isSameAttempt(item, request) ? { ...item, uploadStatus: "failed", errorMessage: error.message || "Upload failed", progress: undefined, } : item, ), ); } function startUploads(requests: MediaUploadRequest[]) { const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({ localId: request.localId, file: request.file, signal: replaceController(request.localId).signal, })); const entries = sessionRequests.map(toLocalUpload); setLocalUploads((prev) => upsertUploads(prev, entries)); for (const request of sessionRequests) { void uploadMutateAsync({ file: request.file, signal: request.signal, onProgress: (percent) => { setLocalUploads((prev) => prev.map((item) => isSameAttempt(item, request) ? { ...item, progress: percent } : item, ), ); }, }) .then(() => settleAttempt(request)) .catch((error: Error) => { if (error.name === "AbortError") { settleAttempt(request); return; } failAttempt(request, error); }); } } function retryUpload(localId: string) { const target = localUploads.find((item) => item.id === localId); if (!target?.sourceFile) return; startUploads([ { localId, file: target.sourceFile, onProgress: () => undefined, }, ]); } /** Abort any in-flight attempt for the tile and drop it from the session. */ function dropLocal(localId: string) { controllersRef.current.get(localId)?.abort(); clearController(localId); setLocalUploads((prev) => dropUploads(prev, (item) => item.id === localId)); } function cancelUpload(localId: string) { dropLocal(localId); } function removeLocal(id: string) { dropLocal(id); } function isLocal(id: string) { return localUploads.some((item) => item.id === id); } return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal }; }