mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
478 lines
20 KiB
React
478 lines
20 KiB
React
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
|
import { nanoid } from "nanoid";
|
|
import api from "@/utils/api.util";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { presignAssetUpload, uploadPresigned } from "@/utils/presignedUpload.util";
|
|
import { toast } from "sonner";
|
|
|
|
// ─── Generic authenticated SSE reader ──────────────────────────────────────
|
|
//
|
|
// Native EventSource can't set the Authorization header this app authenticates
|
|
// with, so SSE endpoints are 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 progress signal on
|
|
// top of a request that already carries its own real result, never
|
|
// load-bearing on its own.
|
|
function streamSSE(url, token, onEvent) {
|
|
const controller = new AbortController();
|
|
|
|
(async () => {
|
|
try {
|
|
const res = await fetch(url, {
|
|
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));
|
|
onEvent(data);
|
|
if (data.done) return;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.name !== "AbortError") console.warn("[SSE STREAM]", url, err.message);
|
|
}
|
|
})();
|
|
|
|
return () => controller.abort();
|
|
}
|
|
|
|
// Document-conversion stage progress (compiling/validating/generating) — see
|
|
// convertAssetToMarkdown() below. Backend broadcaster is
|
|
// services/uploadProgress.service.js, keyed by a client-generated job id.
|
|
const streamConvertProgress = (jobId, token, onProgress) =>
|
|
streamSSE(`${import.meta.env.VITE_API_URL}/admin/assets/convert-progress/${jobId}`, token, onProgress);
|
|
|
|
const AssetsContext = createContext(null);
|
|
|
|
export function useAssets() {
|
|
const ctx = useContext(AssetsContext);
|
|
if (!ctx) throw new Error("useAssets must be used within an AssetsProvider");
|
|
return ctx;
|
|
}
|
|
|
|
// ─── Initial States ────────────────────────────────────────────────────────────
|
|
const PAGINATION_INIT = {
|
|
page: 1,
|
|
limit: 10,
|
|
totalRecords: 0,
|
|
totalPages: 0,
|
|
hasPrevPage: false,
|
|
hasNextPage: false,
|
|
};
|
|
|
|
// No Redis yet — these caches are plain in-memory (per browser tab, cleared on
|
|
// refresh) to absorb the repeated open/close traffic pickers like
|
|
// AssetPickerSheet generate against Postgres and the media-token endpoint.
|
|
const LIST_CACHE_TTL_MS = 20_000; // short: just enough to survive rapid open/close flapping
|
|
const MEDIA_TOKEN_TTL_MS = 30 * 60 * 1000; // mirrors TOKEN_TTL_SEC in media.controller.js
|
|
const MEDIA_TOKEN_REFRESH_MARGIN_MS = 2 * 60 * 1000; // re-mint a bit before real expiry
|
|
|
|
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);
|
|
const [selectedAsset, setSelectedAsset] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// { [asset_id]: { token, thumbnail_url, issuedAt } } — shared across every
|
|
// picker instance so tokens survive sheet open/close for their full TTL.
|
|
const [mediaTokens, setMediaTokens] = useState({});
|
|
const mediaTokensRef = useRef({});
|
|
const listCacheRef = useRef(new Map());
|
|
|
|
const invalidateListCache = () => listCacheRef.current.clear();
|
|
|
|
// Seeds mediaTokens from stream_token/thumbnail_url fields the backend now
|
|
// embeds directly in S3 rows of GET /admin/assets — so getMediaTokens (called
|
|
// right after fetchAssets by pickers/tables) finds them already cached and
|
|
// skips the batch round-trip instead of re-requesting tokens it just got.
|
|
const seedMediaTokensFromRows = (rows = []) => {
|
|
const issuedAt = Date.now();
|
|
const next = {};
|
|
for (const row of rows) {
|
|
if (row.stream_token) {
|
|
next[String(row.asset_id)] = { token: row.stream_token, thumbnail_url: row.thumbnail_url ?? null, issuedAt };
|
|
}
|
|
}
|
|
if (Object.keys(next).length) {
|
|
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
|
|
setMediaTokens(mediaTokensRef.current);
|
|
}
|
|
};
|
|
|
|
const request = useCallback(async (fn) => {
|
|
setLoading(true);
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
const message = err?.response?.data?.message || err.message || "Something went wrong.";
|
|
toast(message);
|
|
return null;
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// ─── GET /api/admin/assets ────────────────────────────────────────────────
|
|
// Cached per (page, limit, filters, sort) for LIST_CACHE_TTL_MS so toggling
|
|
// a picker like AssetPickerSheet open/closed doesn't re-hit Postgres for the
|
|
// same query within the TTL window. Pass force: true to bypass the cache.
|
|
const fetchAssets = useCallback(
|
|
({ page = 1, limit = 10, filters = [], sort = [], force = false } = {}) => {
|
|
const key = cacheKeyFor("assets", { page, limit, filters, sort });
|
|
const cached = listCacheRef.current.get(key);
|
|
if (!force && cached && Date.now() - cached.fetchedAt < LIST_CACHE_TTL_MS) {
|
|
setAssets(cached.assets);
|
|
setPagination(cached.pagination);
|
|
setAttributes(cached.attributes);
|
|
seedMediaTokensFromRows(cached.assets);
|
|
return Promise.resolve(cached.raw);
|
|
}
|
|
|
|
return request(async () => {
|
|
const { data } = await api.get("/admin/assets", {
|
|
params: {
|
|
page, limit,
|
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
|
},
|
|
});
|
|
|
|
const result = data?.data;
|
|
|
|
setAssets(result?.data ?? []);
|
|
setPagination(result?.pagination ?? PAGINATION_INIT);
|
|
setAttributes(result.attributes);
|
|
seedMediaTokensFromRows(result?.data);
|
|
|
|
listCacheRef.current.set(key, {
|
|
assets: result?.data ?? [],
|
|
pagination: result?.pagination ?? PAGINATION_INIT,
|
|
attributes: result.attributes,
|
|
raw: data.data,
|
|
fetchedAt: Date.now(),
|
|
});
|
|
|
|
return data.data;
|
|
});
|
|
},
|
|
[request]
|
|
);
|
|
|
|
// ─── POST /api/admin/media/tokens (batch) ────────────────────────────────
|
|
// Skips any asset_id whose cached token is still within its TTL (minus a
|
|
// safety margin) instead of re-minting a fresh JWT/presigned URL every time
|
|
// a picker reopens. Shared across all picker instances via context state.
|
|
const getMediaTokens = useCallback((assetIds = []) => {
|
|
const now = Date.now();
|
|
const missing = assetIds
|
|
.map(String)
|
|
.filter((id) => {
|
|
const cached = mediaTokensRef.current[id];
|
|
return !cached || (now - cached.issuedAt) > (MEDIA_TOKEN_TTL_MS - MEDIA_TOKEN_REFRESH_MARGIN_MS);
|
|
});
|
|
|
|
if (!missing.length) return Promise.resolve(mediaTokensRef.current);
|
|
|
|
return api.post("/admin/media/tokens", { asset_ids: missing }).then(({ data }) => {
|
|
const tokens = data.data?.tokens ?? {};
|
|
const thumbnails = data.data?.thumbnails ?? {};
|
|
const issuedAt = Date.now();
|
|
|
|
const next = {};
|
|
for (const [id, token] of Object.entries(tokens)) {
|
|
next[id] = { token, thumbnail_url: thumbnails[id] ?? null, issuedAt };
|
|
}
|
|
|
|
mediaTokensRef.current = { ...mediaTokensRef.current, ...next };
|
|
setMediaTokens(mediaTokensRef.current);
|
|
return mediaTokensRef.current;
|
|
});
|
|
}, []);
|
|
|
|
// ─── GET /api/admin/assets/:assetId ───────────────────────────────────────
|
|
const fetchAsset = useCallback(
|
|
(assetId) =>
|
|
request(async () => {
|
|
const res = await api.get(`/admin/assets/${assetId}`);
|
|
setSelectedAsset(res.data?.data?.data ?? null);
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── GET /api/admin/assets/archived ───────────────────────────────────────
|
|
const fetchArchivedAssets = useCallback(
|
|
({ page = 1, limit = 10, filters = [], sort = [] } = {}) =>
|
|
request(async () => {
|
|
const { data } = await api.get("/admin/assets/archived", {
|
|
params: {
|
|
page, limit,
|
|
filters: filters.length ? JSON.stringify(filters) : undefined,
|
|
sort: sort.length ? JSON.stringify(sort) : undefined,
|
|
},
|
|
});
|
|
const final_data = data?.data;
|
|
|
|
setAssets(final_data?.data ?? []);
|
|
setPagination(final_data?.pagination ?? PAGINATION_INIT);
|
|
setAttributes(final_data.attributes);
|
|
return data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── POST /api/admin/assets/presign + direct PUT + POST /api/admin/assets ──
|
|
//
|
|
// onProgress?: ({ phase: 'uploading'|'processing'|'done', pct }) => void
|
|
// "uploading" — browser -> storage, real bytes sent directly (this
|
|
// backend is never in that data path at all anymore).
|
|
// "processing" — brief server-side step once the upload lands: reads the
|
|
// object back (HeadObjectCommand), runs ffprobe for
|
|
// video/audio, inserts the DB row.
|
|
const uploadAsset = useCallback(
|
|
({ file, thumbnail, onProgress, ...rest }) =>
|
|
request(async () => {
|
|
const [mainPresign, thumbPresign] = await Promise.all([
|
|
presignAssetUpload(file),
|
|
thumbnail ? presignAssetUpload(thumbnail) : Promise.resolve(null),
|
|
]);
|
|
const storage_key = mainPresign.key;
|
|
|
|
await Promise.all([
|
|
uploadPresigned(file, mainPresign, (pct) => onProgress?.({ phase: "uploading", pct })),
|
|
thumbPresign ? uploadPresigned(thumbnail, thumbPresign) : Promise.resolve(),
|
|
]);
|
|
|
|
onProgress?.({ phase: "processing", pct: 100 });
|
|
|
|
const res = await api.post("/admin/assets", {
|
|
storage_key,
|
|
thumbnail_storage_key: thumbPresign?.key,
|
|
original_name: file.name,
|
|
// Fallback only — the backend prefers storage's own
|
|
// Content-Type, this just covers the rare case a browser
|
|
// sent the PUT with no Content-Type at all (empty File.type).
|
|
mimetype: file.type || undefined,
|
|
...rest,
|
|
});
|
|
|
|
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;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── POST /api/admin/assets/:assetId/convert-to-markdown ─────────────────
|
|
//
|
|
// PDF/PPTX -> Markdown, text only (see documentConversion.service.js on
|
|
// the backend for why OCR/images are out of scope). Nothing is persisted
|
|
// by this call — the result is a draft the caller (Document Import block)
|
|
// only keeps if the admin explicitly inserts it. On failure this resolves
|
|
// to null (the shared `request()` wrapper already toasts the backend's
|
|
// specific error message, e.g. "No readable text found...").
|
|
//
|
|
// onProgress?: ({ phase: 'compiling'|'validating'|'generating'|'done'|'error' }) => void
|
|
const convertAssetToMarkdown = useCallback(
|
|
(assetId, { onProgress } = {}) =>
|
|
request(async () => {
|
|
const jobId = nanoid();
|
|
const stopStream = onProgress
|
|
? streamConvertProgress(jobId, accessTokenRef.current, onProgress)
|
|
: null;
|
|
|
|
try {
|
|
const res = await api.post(`/admin/assets/${assetId}/convert-to-markdown`, { jobId });
|
|
return res.data?.data ?? null;
|
|
} finally {
|
|
stopStream?.();
|
|
}
|
|
}),
|
|
[request, accessTokenRef]
|
|
);
|
|
|
|
// ─── PATCH /api/admin/assets/:assetId ────────────────────────────────────
|
|
//
|
|
// A replacement file (video thumbnail, or an image/audio asset's main
|
|
// file) goes out the same presigned direct-to-storage path as
|
|
// uploadAsset() above — this backend never buffers it. The PATCH body
|
|
// itself is plain JSON either way (storage_key when a file was replaced).
|
|
const updateAsset = useCallback(
|
|
(assetId, fields, file = null) =>
|
|
request(async () => {
|
|
let filePayload = {};
|
|
if (file) {
|
|
const presigned = await presignAssetUpload(file);
|
|
await uploadPresigned(file, presigned);
|
|
filePayload = {
|
|
storage_key: presigned.key,
|
|
original_name: file.name,
|
|
mimetype: file.type || undefined,
|
|
};
|
|
}
|
|
|
|
const res = await api.patch(`/admin/assets/${assetId}`, { ...fields, ...filePayload });
|
|
|
|
const asset = res.data?.data?.data ?? null;
|
|
if (asset) {
|
|
setAssets((prev) => prev.map((a) => (a.asset_id === assetId ? asset : a)));
|
|
setSelectedAsset(asset);
|
|
invalidateListCache();
|
|
toast("Asset updated successfully.");
|
|
}
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── DELETE /api/admin/assets/:assetId ───────────────────────────────────
|
|
const archiveAsset = useCallback(
|
|
(assetId, { deletedBy } = {}) =>
|
|
request(async () => {
|
|
const res = await api.delete(`/admin/assets/${assetId}`, {
|
|
data: { deletedBy },
|
|
});
|
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
|
invalidateListCache();
|
|
toast("Asset archived.");
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── DELETE /api/admin/assets/bulk ───────────────────────────────────────
|
|
const archiveAssets = useCallback(
|
|
({ ids }, { deletedBy } = {}) =>
|
|
request(async () => {
|
|
const res = await api.delete("/admin/assets/bulk", {
|
|
data: { ids, deletedBy },
|
|
});
|
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
|
invalidateListCache();
|
|
toast(`${ids.length} asset(s) archived.`);
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── PATCH /api/admin/assets/:assetId/restore ────────────────────────────
|
|
const restoreAsset = useCallback(
|
|
(assetId) =>
|
|
request(async () => {
|
|
const res = await api.patch(`/admin/assets/${assetId}/restore`);
|
|
const asset = res.data?.data?.data ?? null;
|
|
if (asset) {
|
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
|
invalidateListCache();
|
|
toast("Asset restored.");
|
|
}
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── PATCH /api/admin/assets/bulk-restore ────────────────────────────────
|
|
const restoreAssets = useCallback(
|
|
({ ids }) =>
|
|
request(async () => {
|
|
const res = await api.patch("/admin/assets/bulk-restore", { ids });
|
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
|
invalidateListCache();
|
|
toast(`${ids.length} asset(s) restored.`);
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── DELETE /api/admin/assets/:assetId/permanent ─────────────────────────
|
|
const permanentlyDeleteAsset = useCallback(
|
|
(assetId) =>
|
|
request(async () => {
|
|
const res = await api.delete(`/admin/assets/${assetId}/permanent`);
|
|
setAssets((prev) => prev.filter((a) => a.asset_id !== assetId));
|
|
setSelectedAsset((prev) => (prev?.asset_id === assetId ? null : prev));
|
|
invalidateListCache();
|
|
toast("Asset permanently deleted.");
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── DELETE /api/admin/assets/bulk/permanent ─────────────────────────────
|
|
const permanentlyDeleteAssets = useCallback(
|
|
({ ids }) =>
|
|
request(async () => {
|
|
const res = await api.delete("/admin/assets/bulk/permanent", {
|
|
data: { ids },
|
|
});
|
|
setAssets((prev) => prev.filter((a) => !ids.includes(a.asset_id)));
|
|
invalidateListCache();
|
|
toast(`${ids.length} asset(s) permanently deleted.`);
|
|
return res.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
// ─── GET /api/admin/assets/field-values ──────────────────────────────────────
|
|
const fetchAssetFieldValues = useCallback(
|
|
(field) =>
|
|
request(async () => {
|
|
const res = await api.get("/admin/assets/field-values", { params: { field } });
|
|
return res.data?.data;
|
|
}),
|
|
[request]
|
|
);
|
|
|
|
return (
|
|
<AssetsContext.Provider value={{
|
|
assets,
|
|
attributes,
|
|
pagination,
|
|
selectedAsset,
|
|
loading,
|
|
mediaTokens,
|
|
getMediaTokens,
|
|
setPagination,
|
|
setSelectedAsset,
|
|
fetchAssets,
|
|
fetchAsset,
|
|
fetchArchivedAssets,
|
|
uploadAsset,
|
|
convertAssetToMarkdown,
|
|
updateAsset,
|
|
archiveAsset,
|
|
archiveAssets,
|
|
restoreAsset,
|
|
restoreAssets,
|
|
permanentlyDeleteAsset,
|
|
permanentlyDeleteAssets,
|
|
fetchAssetFieldValues
|
|
}}>
|
|
{children}
|
|
</AssetsContext.Provider>
|
|
);
|
|
} |