mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -1,7 +1,52 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import api from "@/utils/api.util";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// ─── Upload progress stream (Express -> Garage, real bytes) ───────────────────
|
||||
//
|
||||
// Native EventSource can't set the Authorization header this app authenticates
|
||||
// with, so GET /admin/assets/upload-progress/:uploadId is consumed via a
|
||||
// manually-parsed, authenticated fetch() stream instead of EventSource.
|
||||
// Returns a stop() function. Failures here are swallowed on purpose — this is
|
||||
// a best-effort visual on top of the real upload, never load-bearing for it.
|
||||
function streamUploadProgress(uploadId, token, onProgress) {
|
||||
const controller = new AbortController();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${import.meta.env.VITE_API_URL}/admin/assets/upload-progress/${uploadId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop(); // keep the last, possibly-incomplete chunk for next read
|
||||
for (const chunk of chunks) {
|
||||
const line = chunk.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!line) continue;
|
||||
const data = JSON.parse(line.slice(6));
|
||||
onProgress(data);
|
||||
if (data.done) return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") console.warn("[ASSET][UPLOAD PROGRESS STREAM]", err.message);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}
|
||||
|
||||
const AssetsContext = createContext(null);
|
||||
|
||||
export function useAssets() {
|
||||
@@ -31,6 +76,7 @@ const cacheKeyFor = (scope, { page, limit, filters, sort }) =>
|
||||
`${scope}:${JSON.stringify({ page, limit, filters, sort })}`;
|
||||
|
||||
export function AssetsProvider({ children }) {
|
||||
const { accessTokenRef } = useAuth();
|
||||
const [assets, setAssets] = useState([]);
|
||||
const [attributes, setAttributes] = useState([]);
|
||||
const [pagination, setPagination] = useState(PAGINATION_INIT);
|
||||
@@ -186,26 +232,51 @@ export function AssetsProvider({ children }) {
|
||||
);
|
||||
|
||||
// ─── POST /api/admin/assets ───────────────────────────────────────────────
|
||||
//
|
||||
// onProgress?: ({ phase: 'uploading'|'storing'|'done'|'error', pct }) => void
|
||||
// Two honest, sequential phases — not one blended/estimated number:
|
||||
// "uploading" — browser -> this backend, real bytes sent (axios onUploadProgress).
|
||||
// "storing" — this backend -> Garage, the actual S3 PUT, streamed live over
|
||||
// SSE (see streamUploadProgress above / uploadProgress.service.js
|
||||
// on the backend). Real numbers from the real transfer, both legs.
|
||||
const uploadAsset = useCallback(
|
||||
({ file, thumbnail, ...rest }) =>
|
||||
({ file, thumbnail, onProgress, ...rest }) =>
|
||||
request(async () => {
|
||||
const uploadId = nanoid();
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (thumbnail) form.append("thumbnail", thumbnail);
|
||||
form.append("uploadId", uploadId);
|
||||
Object.entries(rest).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null) form.append(k, v);
|
||||
});
|
||||
|
||||
const res = await api.post("/admin/assets", form);
|
||||
const asset = res.data?.data?.data ?? null;
|
||||
if (asset) {
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
invalidateListCache();
|
||||
toast("Asset uploaded successfully.");
|
||||
const stopStream = onProgress
|
||||
? streamUploadProgress(uploadId, accessTokenRef.current, onProgress)
|
||||
: null;
|
||||
|
||||
try {
|
||||
const res = await api.post("/admin/assets", form, {
|
||||
onUploadProgress: onProgress
|
||||
? (evt) => {
|
||||
const pct = evt.total ? Math.round((evt.loaded / evt.total) * 100) : 0;
|
||||
onProgress({ phase: "uploading", pct });
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const asset = res.data?.data?.data ?? null;
|
||||
if (asset) {
|
||||
setAssets((prev) => [asset, ...prev]);
|
||||
invalidateListCache();
|
||||
toast("Asset uploaded successfully.");
|
||||
}
|
||||
onProgress?.({ phase: "done", pct: 100 });
|
||||
return res.data;
|
||||
} finally {
|
||||
stopStream?.();
|
||||
}
|
||||
return res.data;
|
||||
}),
|
||||
[request]
|
||||
[request, accessTokenRef]
|
||||
);
|
||||
|
||||
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user