mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
165 lines
7.2 KiB
React
165 lines
7.2 KiB
React
// contexts/UploadQueueContext.jsx
|
|
//
|
|
// Global, app-shell-mounted upload queue for the Add Asset multi-file drop
|
|
// zone. Lives above the router (see AdminProvider.jsx) so an in-flight
|
|
// batch survives navigating away from the Add Asset page — the floating
|
|
// UploadProgressToast reads the same state from anywhere in /admin.
|
|
//
|
|
// Every job — whether it's part of an initial addBatch() or a single retry —
|
|
// goes out as its OWN POST /admin/assets/batch request (that endpoint has
|
|
// always accepted 1-20 files; sending 1 is a fully supported path). This
|
|
// matters beyond just per-file progress: the backend processes a batch
|
|
// request's files sequentially inside one request/response cycle and never
|
|
// aborts on client disconnect, so a connection drop anywhere during a
|
|
// combined multi-file request used to fail EVERY job in that request, even
|
|
// ones the server had already finished (S3 object + DB row both created) —
|
|
// clicking Retry on those then silently created a duplicate. One request per
|
|
// file means a dropped connection can only affect the one job riding it.
|
|
// uploadOne() below is shared by both the initial run and retries; runBatch()
|
|
// just fans it out over a small worker pool so a big batch doesn't open 20
|
|
// connections at once (MAX_CONCURRENT mirrors garage-anon-proxy's own
|
|
// MAX_CONCURRENT_UPLOADS=3 ceiling on the S3 side, so the frontend doesn't
|
|
// just relocate the pile-up into tripping that limit instead).
|
|
|
|
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
import { nanoid } from "nanoid";
|
|
import api from "@/utils/api.util";
|
|
import { validateAssetFile } from "@/utils/assetUpload.util";
|
|
|
|
const MAX_CONCURRENT = 3;
|
|
|
|
const UploadQueueContext = createContext(null);
|
|
|
|
export function useUploadQueue() {
|
|
const ctx = useContext(UploadQueueContext);
|
|
if (!ctx) throw new Error("useUploadQueue must be used within an UploadQueueProvider");
|
|
return ctx;
|
|
}
|
|
|
|
const baseNameOf = (filename = "") => filename.replace(/\.[^.]+$/, "");
|
|
|
|
export function UploadQueueProvider({ children }) {
|
|
const [jobs, setJobs] = useState([]);
|
|
const onSettledRef = useRef(new Map()); // batchId -> callback
|
|
|
|
const patchJobs = useCallback((ids, patch) => {
|
|
const idSet = new Set(ids);
|
|
setJobs((prev) => prev.map((j) => (idSet.has(j.id) ? { ...j, ...(typeof patch === "function" ? patch(j) : patch) } : j)));
|
|
}, []);
|
|
|
|
// ─── Upload exactly one job through its own request ────────────────────
|
|
// Shared by the initial batch run and single-job retries — a dropped
|
|
// connection here can only ever fail the one job riding this request,
|
|
// never its batch-mates.
|
|
const uploadOne = useCallback(async (job) => {
|
|
const form = new FormData();
|
|
form.append("files", job.file);
|
|
Object.entries(job.meta).forEach(([k, v]) => {
|
|
if (v !== undefined && v !== null) form.append(k, v);
|
|
});
|
|
// Bulk display_name derivation only applies when the caller didn't
|
|
// already provide one — each job keeps whatever name it resolves to.
|
|
if (!job.meta.display_name) form.append("display_name", baseNameOf(job.name));
|
|
|
|
try {
|
|
const { data } = await api.post("/admin/assets/batch", form, {
|
|
onUploadProgress: (evt) => {
|
|
const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0;
|
|
patchJobs([job.id], { progress: pct });
|
|
},
|
|
});
|
|
const result = data?.data?.results?.[0];
|
|
patchJobs([job.id], result?.success
|
|
? { status: "uploaded", progress: 100, asset: result.data, error: null }
|
|
: { status: "failed", progress: 100, error: result?.message || "Upload failed." });
|
|
} catch (err) {
|
|
// No sonner toast() here — the floating widget (both corners
|
|
// would collide, see UploadProgressToast) already surfaces this
|
|
// via the job's own failed status.
|
|
const message = err?.response?.data?.message ?? "Upload failed.";
|
|
patchJobs([job.id], { status: "failed", progress: 100, error: message });
|
|
}
|
|
}, [patchJobs]);
|
|
|
|
// ─── Run a batch through a small fixed-size worker pool ────────────────
|
|
// One POST per file (uploadOne) fanned out over MAX_CONCURRENT workers,
|
|
// instead of one request for the whole batch — isolates a dropped
|
|
// connection to the single job it was carrying instead of failing every
|
|
// job in the batch (which is what used to happen).
|
|
const runBatch = useCallback(async (batchId, batchJobs) => {
|
|
const ids = batchJobs.map((j) => j.id);
|
|
patchJobs(ids, { status: "uploading", progress: 0 });
|
|
|
|
let next = 0;
|
|
const worker = async () => {
|
|
while (next < batchJobs.length) {
|
|
await uploadOne(batchJobs[next++]);
|
|
}
|
|
};
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(MAX_CONCURRENT, batchJobs.length) }, worker)
|
|
);
|
|
|
|
onSettledRef.current.get(batchId)?.();
|
|
onSettledRef.current.delete(batchId);
|
|
}, [patchJobs, uploadOne]);
|
|
|
|
// files: File[]; meta: { is_public, storage_provider, createdBy }
|
|
const addBatch = useCallback((files, meta, { onSettled } = {}) => {
|
|
const batchId = nanoid();
|
|
const newJobs = files.map((file) => {
|
|
const { ok, reason } = validateAssetFile(file);
|
|
return {
|
|
id: nanoid(),
|
|
batchId,
|
|
file,
|
|
name: file.name,
|
|
size: file.size,
|
|
mime: file.type,
|
|
status: ok ? "queued" : "invalid",
|
|
progress: ok ? 0 : 100,
|
|
error: ok ? null : reason,
|
|
asset: null,
|
|
meta,
|
|
};
|
|
});
|
|
|
|
setJobs((prev) => [...prev, ...newJobs]);
|
|
|
|
const uploadable = newJobs.filter((j) => j.status === "queued");
|
|
if (onSettled) {
|
|
if (uploadable.length) onSettledRef.current.set(batchId, onSettled);
|
|
else onSettled();
|
|
}
|
|
if (uploadable.length) runBatch(batchId, uploadable);
|
|
|
|
return batchId;
|
|
}, [runBatch]);
|
|
|
|
// ─── Retry a single failed job ──────────────────────────────────────────
|
|
const retryJob = useCallback(async (jobId) => {
|
|
let target = null;
|
|
setJobs((prev) => prev.map((j) => {
|
|
if (j.id !== jobId) return j;
|
|
target = j;
|
|
return { ...j, status: "uploading", progress: 0, error: null };
|
|
}));
|
|
if (!target) return;
|
|
await uploadOne(target);
|
|
}, [uploadOne]);
|
|
|
|
const removeJob = useCallback((jobId) => {
|
|
setJobs((prev) => prev.filter((j) => j.id !== jobId || j.status === "uploading"));
|
|
}, []);
|
|
|
|
const clearFinished = useCallback(() => {
|
|
setJobs((prev) => prev.filter((j) => j.status === "uploading" || j.status === "queued"));
|
|
}, []);
|
|
|
|
return (
|
|
<UploadQueueContext.Provider value={{ jobs, addBatch, retryJob, removeJob, clearFinished }}>
|
|
{children}
|
|
</UploadQueueContext.Provider>
|
|
);
|
|
}
|