shoc-frontend-new/src/app/(protected)/workorders/_hooks/use-media-upload-session.ts

211 lines
6.4 KiB
TypeScript

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<unknown>;
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<LocalUpload[]>([]);
const controllersRef = useRef(new Map<string, AbortController>());
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 };
}