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 /