mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -0,0 +1,160 @@
|
||||
// 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.
|
||||
//
|
||||
// One POST /admin/assets/batch per addBatch() call — axios onUploadProgress
|
||||
// only reports aggregate bytes for the whole multipart body, so every job in
|
||||
// a batch shares one progress number while "uploading" and only splits into
|
||||
// per-file uploaded/failed once the response comes back. Retries re-submit
|
||||
// a single file through the same endpoint so they can track their own
|
||||
// progress independently of whatever batch they originally belonged to.
|
||||
|
||||
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 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)));
|
||||
}, []);
|
||||
|
||||
// ─── Submit a batch of already-validated files ─────────────────────────
|
||||
const runBatch = useCallback(async (batchId, batchJobs, meta) => {
|
||||
const ids = batchJobs.map((j) => j.id);
|
||||
patchJobs(ids, { status: "uploading", progress: 0 });
|
||||
|
||||
const form = new FormData();
|
||||
batchJobs.forEach((j) => form.append("files", j.file));
|
||||
Object.entries(meta).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) form.append(k, v);
|
||||
});
|
||||
|
||||
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(ids, { progress: pct });
|
||||
},
|
||||
});
|
||||
|
||||
const results = data?.data?.results ?? [];
|
||||
results.forEach((result, idx) => {
|
||||
const job = batchJobs[idx];
|
||||
if (!job) return;
|
||||
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 each job's failed status.
|
||||
const message = err?.response?.data?.message ?? "Upload failed.";
|
||||
patchJobs(ids, { status: "failed", progress: 100, error: message });
|
||||
} finally {
|
||||
onSettledRef.current.get(batchId)?.();
|
||||
onSettledRef.current.delete(batchId);
|
||||
}
|
||||
}, [patchJobs]);
|
||||
|
||||
// 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, meta);
|
||||
|
||||
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;
|
||||
|
||||
const form = new FormData();
|
||||
form.append("files", target.file);
|
||||
Object.entries(target.meta).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) form.append(k, v);
|
||||
});
|
||||
// Bulk display_name derivation only applies when >1 file — a lone
|
||||
// retry should keep whatever name the file originally resolved to.
|
||||
if (!target.meta.display_name) form.append("display_name", baseNameOf(target.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([jobId], { progress: pct });
|
||||
},
|
||||
});
|
||||
const result = data?.data?.results?.[0];
|
||||
patchJobs([jobId], result?.success
|
||||
? { status: "uploaded", progress: 100, asset: result.data, error: null }
|
||||
: { status: "failed", progress: 100, error: result?.message || "Upload failed." });
|
||||
} catch (err) {
|
||||
const message = err?.response?.data?.message ?? "Upload failed.";
|
||||
patchJobs([jobId], { status: "failed", progress: 100, error: message });
|
||||
}
|
||||
}, [patchJobs]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user