diff --git a/src/components/generic/Table/ColumnActionsDropdown.jsx b/src/components/generic/Table/ColumnActionsDropdown.jsx index 25512fd..bedb2c6 100644 --- a/src/components/generic/Table/ColumnActionsDropdown.jsx +++ b/src/components/generic/Table/ColumnActionsDropdown.jsx @@ -40,6 +40,7 @@ export function ColumnActionsDropdown({ const column = header.column; const sorted = column.getIsSorted(); const canFilter = showFilter ?? !!attr; + const canSort = column.getCanSort(); return ( @@ -63,20 +64,24 @@ export function ColumnActionsDropdown({ Column Actions - column.toggleSorting(false)}> - - Sort Asc - + {canSort && ( + <> + column.toggleSorting(false)}> + + Sort Asc + - column.toggleSorting(true)}> - - Sort Desc - + column.toggleSorting(true)}> + + Sort Desc + - column.clearSorting()}> - - Clear Sort - + column.clearSorting()}> + + Clear Sort + + + )} {canFilter && ( <> diff --git a/src/contexts/AdminAssetsContext.jsx b/src/contexts/AdminAssetsContext.jsx index 8edaa21..8a85e04 100644 --- a/src/contexts/AdminAssetsContext.jsx +++ b/src/contexts/AdminAssetsContext.jsx @@ -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 ──────────────────────────────────── diff --git a/src/hooks/useAssetFetchState.js b/src/hooks/useAssetFetchState.js new file mode 100644 index 0000000..f9043e8 --- /dev/null +++ b/src/hooks/useAssetFetchState.js @@ -0,0 +1,33 @@ +// hooks/useAssetFetchState.js +// +// Fetches a single asset by id and reports a render-safe status for the +// admin ViewXAsset pages. Guards the ~1s window where AssetsContext's +// shared selectedAsset still holds the *previous* asset (from an earlier +// /view/*/:assetId navigation) while the new fetch is in flight — without +// this, the stale asset (and its already-resolved preview image/token) +// briefly renders under the new URL before the real data arrives. +import { useEffect, useState } from "react"; +import { useAssets } from "@/contexts/AdminAssetsContext"; + +export function useAssetFetchState(assetId) { + const { selectedAsset, loading, fetchAsset } = useAssets(); + const [fetchedAssetId, setFetchedAssetId] = useState(null); + + useEffect(() => { + if (!assetId) return; + let active = true; + fetchAsset(assetId).finally(() => { + if (active) setFetchedAssetId(assetId); + }); + return () => { active = false; }; + }, [assetId, fetchAsset]); + + const isCurrent = !!selectedAsset && String(selectedAsset.asset_id) === String(assetId); + const hasAttemptedCurrent = fetchedAssetId === assetId; + + return { + asset: isCurrent ? selectedAsset : null, + loading: loading || (!isCurrent && !hasAttemptedCurrent), + notFound: !loading && hasAttemptedCurrent && !isCurrent, + }; +} diff --git a/src/hooks/useAssetPreviewSrc.js b/src/hooks/useAssetPreviewSrc.js index a0dab43..8f52a01 100644 --- a/src/hooks/useAssetPreviewSrc.js +++ b/src/hooks/useAssetPreviewSrc.js @@ -22,7 +22,15 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) { // re-fire the effect (and its setState calls) on every render, looping // forever since each firing produces a new src ("" vs null) that never // stabilizes. - const { asset_id, storage_provider, file_url, thumbnail_url } = asset ?? {}; + // + // stream_token must be carried through: getAsset()/getAssets() already + // embed a valid token on S3-backed rows (attachStreamTokens), and + // resolveAssetSrc() checks it first. Dropping it here forced every + // preview — on every View/Edit asset page — through the async + // fetchAssetPreviewSrc() mint round-trip even when a usable token was + // already sitting on the asset, which is most of the visible ~1s delay + // before the real image/video appears. + const { asset_id, storage_provider, file_url, thumbnail_url, stream_token } = asset ?? {}; useEffect(() => { setSrc(null); @@ -30,7 +38,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) { if (!asset_id && !file_url && !thumbnail_url) return; - const currentAsset = { asset_id, storage_provider, file_url, thumbnail_url }; + const currentAsset = { asset_id, storage_provider, file_url, thumbnail_url, stream_token }; const fastSrc = resolveAssetSrc(currentAsset); if (fastSrc) { @@ -50,7 +58,7 @@ export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) { }); return () => { cancelled = true; }; - }, [asset_id, storage_provider, file_url, thumbnail_url, scope]); + }, [asset_id, storage_provider, file_url, thumbnail_url, stream_token, scope]); return { src, thumbnailUrl, loading }; } diff --git a/src/modules/admin/components/users/UserTable.jsx b/src/modules/admin/components/users/UserTable.jsx index f729617..e3b6df1 100644 --- a/src/modules/admin/components/users/UserTable.jsx +++ b/src/modules/admin/components/users/UserTable.jsx @@ -110,23 +110,14 @@ export default function UsersTable() { fetchUsers({ page: 1, limit: pagination.limit }); }; - // ─── Attach filterId + filterValue to each stat so TableDashboard - // knows which column/value to apply when clicked ────────────────────── - const dashboardStats = (usersDashboard?.stats ?? []).map((s) => ({ // ← was dashboard?.users?.stats + // ─── Attach filterId/filterValue (or onClick) to each stat ──────────────── + const statsWithFilter = (usersDashboard?.stats ?? []).map((s) => ({ ...s, - filterId: s.key === "archived" || s.key === "total" ? null : "is_active", - filterValue: s.key === "active" ? ["true"] - : s.key === "inactive" ? ["false"] - : null, + filterId: s.key === "active" ? "is_active" : null, + filterValue: s.key === "active" ? ["true"] : null, + onClick: s.key === "inactive" ? () => navigate("/admin/users/archived") : undefined, })); - // Override verified → correct column - const statsWithFilter = dashboardStats.map((s) => - s.key === "verified" - ? { ...s, filterId: "is_verified", filterValue: ["true"] } - : s - ); - // ─── Attach filterId to each breakdown so clicking a slice filters ──────── const dashboardBreakdowns = (usersDashboard?.breakdowns ?? []).map((b) => ({ ...b, diff --git a/src/modules/admin/config/users/columns.config.jsx b/src/modules/admin/config/users/columns.config.jsx index c2cbf62..7d08adc 100644 --- a/src/modules/admin/config/users/columns.config.jsx +++ b/src/modules/admin/config/users/columns.config.jsx @@ -35,9 +35,15 @@ const cellOverrides = { export function buildDataColumns(attributes, rowActions) { const visibleAttributes = attributes.filter((a) => !a.hidden); + const dataColumns = buildColumns(visibleAttributes, { cellOverrides }).map((col) => + // "groups" is a joined association, not a real orderable column on the + // Users table — sorting by it would error server-side. + col.id === "groups" ? { ...col, enableSorting: false } : col + ); + return [ buildSelectionColumn(), - ...buildColumns(visibleAttributes, { cellOverrides }), + ...dataColumns, buildRowActionsColumn(rowActions, { dropdownLabel: "User Actions" }), ]; } \ No newline at end of file diff --git a/src/modules/admin/pages/assets/AddAsset.jsx b/src/modules/admin/pages/assets/AddAsset.jsx index 56be841..0613f8a 100644 --- a/src/modules/admin/pages/assets/AddAsset.jsx +++ b/src/modules/admin/pages/assets/AddAsset.jsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Spinner } from "@/components/ui/spinner"; +import { Progress } from "@/components/ui/progress"; import { Select, SelectContent, @@ -122,6 +123,7 @@ export default function AddAsset() { const fileRef = useRef(null); const thumbnailRef = useRef(null); const [thumbKey, setThumbKey] = useState(0); + const [progress, setProgress] = useState(null); // { phase: 'uploading'|'storing'|'done'|'error', pct } | null const { register, @@ -182,6 +184,7 @@ export default function AddAsset() { if (hasFileError) return; + setProgress({ phase: "uploading", pct: 0 }); const result = await uploadAsset({ file: fileRef.current, thumbnail: thumbnailRef.current ?? undefined, @@ -191,11 +194,20 @@ export default function AddAsset() { is_public: data.is_public === "true", storage_provider: data.storage_provider, createdBy: user?.user_id, + onProgress: setProgress, }); + setProgress(null); if (result) { bypassOnce(); navigate(-1); } }; + const progressLabel = { + uploading: "Uploading to server…", + storing: "Storing to server...", + done: "Done.", + error: "Upload failed.", + }[progress?.phase]; + return (
@@ -352,6 +364,17 @@ export default function AddAsset() {
+ {/* ── Upload progress (real — see AdminAssetsContext.uploadAsset) ── */} + {progress && ( +
+
+ {progressLabel} + {progress.pct ?? 0}% +
+ +
+ )} + {/* ── Actions ── */}
{user.is_banned ? (