diff --git a/src/hooks/useAssetPreviewSrc.js b/src/hooks/useAssetPreviewSrc.js
new file mode 100644
index 0000000..84d5b56
--- /dev/null
+++ b/src/hooks/useAssetPreviewSrc.js
@@ -0,0 +1,46 @@
+// hooks/useAssetPreviewSrc.js
+//
+// React binding around utils/media.util.js's resolveAssetSrc/fetchAssetPreviewSrc.
+// Replaces the duplicated useState+useEffect token-resolution block that used
+// to live in each admin ViewXAsset page.
+//
+// Always resolves a preview regardless of is_public — admins need to see the
+// real asset to manage it. (The download-only-no-preview treatment is a
+// client-facing concept, not an admin one — see downloadAsset()/saveBlob() in
+// media.util.js for the download affordance, used alongside the preview here.)
+import { useEffect, useState } from "react";
+import { resolveAssetSrc, fetchAssetPreviewSrc } from "@/utils/media.util";
+
+export function useAssetPreviewSrc(asset, { scope = "admin" } = {}) {
+ const [src, setSrc] = useState(null);
+ const [thumbnailUrl, setThumbnailUrl] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ setSrc(null);
+ setThumbnailUrl(null);
+
+ if (!asset) return;
+
+ const fastSrc = resolveAssetSrc(asset);
+ if (fastSrc) {
+ setSrc(fastSrc);
+ setThumbnailUrl(asset.thumbnail_url ?? null);
+ return;
+ }
+
+ let cancelled = false;
+ setLoading(true);
+ fetchAssetPreviewSrc(asset, { scope }).then((result) => {
+ if (cancelled) return;
+ setSrc(result.src);
+ setThumbnailUrl(result.thumbnailUrl);
+ }).finally(() => {
+ if (!cancelled) setLoading(false);
+ });
+
+ return () => { cancelled = true; };
+ }, [asset, scope]);
+
+ return { src, thumbnailUrl, loading };
+}
diff --git a/src/modules/admin/pages/assets/ViewAudioAsset.jsx b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
index 48e5430..7317651 100644
--- a/src/modules/admin/pages/assets/ViewAudioAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewAudioAsset.jsx
@@ -1,12 +1,13 @@
// modules/admin/pages/assets/ViewAudioAsset.jsx
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
-import api from "@/utils/api.util";
+import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
+import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
+import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -33,39 +34,12 @@ export default function ViewAudioAsset() {
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
- const [streamUrl, setStreamUrl] = useState(null);
- const [thumbnailUrl, setThumbnailUrl] = useState(null);
+ const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
- useEffect(() => {
- if (!selectedAsset) return;
- setStreamUrl(null);
- setThumbnailUrl(null);
- if (selectedAsset.storage_provider !== "s3") {
- setStreamUrl(selectedAsset.file_url ?? null);
- return;
- }
- const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
- // fetchAsset already embeds stream_token/thumbnail_url for S3 assets —
- // only fall back to a token request if it's somehow missing (expired
- // cache edge case).
- if (selectedAsset.stream_token) {
- setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
- if (selectedAsset.thumbnail_url) setThumbnailUrl(selectedAsset.thumbnail_url);
- return;
- }
- api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
- .then(({ data }) => {
- const { token, thumbnail_url } = data?.data ?? {};
- if (token) setStreamUrl(`${API_BASE}/client/media/stream/${token}`);
- if (thumbnail_url) setThumbnailUrl(thumbnail_url);
- })
- .catch(() => setStreamUrl(null));
- }, [selectedAsset]);
-
if (loading) {
return ;
}
@@ -101,6 +75,9 @@ export default function ViewAudioAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
+
diff --git a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
index 4197235..ca1ea7d 100644
--- a/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewDocumentAsset.jsx
@@ -1,12 +1,13 @@
// modules/admin/pages/assets/ViewDocumentAsset.jsx
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
-import api from "@/utils/api.util";
+import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
+import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
+import { downloadAsset } from "@/utils/media.util";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -30,31 +31,12 @@ export default function ViewDocumentAsset() {
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
- const [streamUrl, setStreamUrl] = useState(null);
+ const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
- useEffect(() => {
- if (!selectedAsset) return;
- setStreamUrl(null);
- if (selectedAsset.storage_provider !== "s3") {
- setStreamUrl(selectedAsset.file_url ?? null);
- return;
- }
- const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
- // fetchAsset already embeds stream_token for S3 assets — only fall back
- // to a token request if it's somehow missing (expired cache edge case).
- if (selectedAsset.stream_token) {
- setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
- return;
- }
- api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
- .then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
- .catch(() => setStreamUrl(null));
- }, [selectedAsset]);
-
if (loading) {
return (
@@ -87,6 +69,9 @@ export default function ViewDocumentAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
+
diff --git a/src/modules/admin/pages/assets/ViewImageAsset.jsx b/src/modules/admin/pages/assets/ViewImageAsset.jsx
index b05ba3f..b46f5f1 100644
--- a/src/modules/admin/pages/assets/ViewImageAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewImageAsset.jsx
@@ -1,12 +1,13 @@
// modules/admin/pages/assets/ViewImageAsset.jsx
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import api from "@/utils/api.util";
-import { ArrowLeft, Lock, Globe } from "lucide-react";
+import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
+import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
+import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -28,31 +29,12 @@ export default function ViewImageAsset() {
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
- const [streamUrl, setStreamUrl] = useState(null);
+ const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
- useEffect(() => {
- if (!selectedAsset) return;
- setStreamUrl(null);
- if (selectedAsset.storage_provider !== "s3") {
- setStreamUrl(selectedAsset.file_url ?? null);
- return;
- }
- const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
- // fetchAsset already embeds stream_token for S3 assets — only fall back
- // to a token request if it's somehow missing (expired cache edge case).
- if (selectedAsset.stream_token) {
- setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
- return;
- }
- api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
- .then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
- .catch(() => setStreamUrl(null));
- }, [selectedAsset]);
-
if (loading) {
return
;
}
@@ -80,6 +62,9 @@ export default function ViewImageAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
+
diff --git a/src/modules/admin/pages/assets/ViewVideoAsset.jsx b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
index 97e853c..96ec6e1 100644
--- a/src/modules/admin/pages/assets/ViewVideoAsset.jsx
+++ b/src/modules/admin/pages/assets/ViewVideoAsset.jsx
@@ -1,12 +1,13 @@
// modules/admin/pages/assets/ViewVideoAsset.jsx
-import { useEffect, useState } from "react";
+import { useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import { ArrowLeft, Lock, Globe } from "lucide-react";
-import api from "@/utils/api.util";
+import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
import { useDateFormat } from "@/hooks/useDateFormat";
import { useAssets } from "@/contexts/AdminAssetsContext";
+import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
+import { downloadAsset } from "@/utils/media.util";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
@@ -38,31 +39,12 @@ export default function ViewVideoAsset() {
const { fmtDateTime } = useDateFormat();
const { selectedAsset, loading, fetchAsset } = useAssets();
- const [streamUrl, setStreamUrl] = useState(null);
+ const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
useEffect(() => {
if (assetId) fetchAsset(assetId);
}, [assetId]);
- useEffect(() => {
- if (!selectedAsset) return;
- setStreamUrl(null);
- if (selectedAsset.storage_provider !== "s3") {
- setStreamUrl(selectedAsset.file_url ?? null);
- return;
- }
- const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
- // fetchAsset already embeds stream_token for S3 assets — only fall back
- // to a token request if it's somehow missing (expired cache edge case).
- if (selectedAsset.stream_token) {
- setStreamUrl(`${API_BASE}/client/media/stream/${selectedAsset.stream_token}`);
- return;
- }
- api.post("/admin/media/token", { asset_id: selectedAsset.asset_id })
- .then(({ data }) => setStreamUrl(`${API_BASE}/client/media/stream/${data?.data?.token}`))
- .catch(() => setStreamUrl(null));
- }, [selectedAsset]);
-
if (loading) {
return
;
}
@@ -90,6 +72,9 @@ export default function ViewVideoAsset() {
{a.display_name ?? a.original_name}
{a.mime_type}
+
diff --git a/src/modules/client/components/FilePreview.jsx b/src/modules/client/components/FilePreview.jsx
index 08ace1b..97c7381 100644
--- a/src/modules/client/components/FilePreview.jsx
+++ b/src/modules/client/components/FilePreview.jsx
@@ -36,6 +36,7 @@ import {
import { formatDate } from '@/utils/table.util';
import { formatBytes } from './blocks/FileUpload';
import api from '@/utils/api.util';
+import { saveBlob } from '@/utils/media.util';
import FileZoomViewer from './FileZoomViewer';
// ─── Resolve preview kind from mime type ──────────────────────────────────────
@@ -190,18 +191,6 @@ const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownl
}
};
-// ─── Trigger a browser download from a blob ────────────────────────────────────
-const downloadBlob = (blob, fileName) => {
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = fileName;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- URL.revokeObjectURL(url);
-};
-
// ─── Main dialog ────────────────────────────────────────────────────────────────
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
@@ -223,7 +212,7 @@ const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
setDownloading(true);
try {
const res = await api.get(url, { responseType: 'blob' });
- downloadBlob(res.data, file.file_name);
+ saveBlob(res.data, file.file_name);
} catch {
// fall back to direct link if proxy fails (e.g. public file_url)
if (file.file_url) window.open(file.file_url, '_blank');
diff --git a/src/utils/media.util.js b/src/utils/media.util.js
index e9987f1..a6d6652 100644
--- a/src/utils/media.util.js
+++ b/src/utils/media.util.js
@@ -1,13 +1,21 @@
// utils/media.util.js
//
-// Resolves a displayable
![]()
src for an asset payload that may carry a
-// short-lived stream_token (private/S3 — proxied through the backend) instead
-// of a raw file_url. Private assets must never expose their real storage URL
-// to the browser — see controllers/admin/assets.controller.js's redactS3Url
-// pattern, mirrored for advertisements in controllers/*/advertisements.controller.js.
+// Resolves a displayable
![]()
/