Files
starr-philproperties/src/contexts/AdminAssetsContext.jsx
T
2026-08-01 12:18:15 +08:00

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>
);
}