mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-04 16:02:08 +00:00
211 lines
6.4 KiB
TypeScript
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 };
|
|
}
|