mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
add: more commits
Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
// modules/admin/pages/assets/ViewAudioAsset.jsx
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, Music2 } from "lucide-react";
|
import { ArrowLeft, Lock, Globe, Music2, Download } from "lucide-react";
|
||||||
import api from "@/utils/api.util";
|
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -33,39 +34,12 @@ export default function ViewAudioAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
const [streamUrl, setStreamUrl] = useState(null);
|
const { src: streamUrl, thumbnailUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assetId) fetchAsset(assetId);
|
if (assetId) fetchAsset(assetId);
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
}
|
}
|
||||||
@@ -101,6 +75,9 @@ export default function ViewAudioAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
// modules/admin/pages/assets/ViewDocumentAsset.jsx
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe, FileText } from "lucide-react";
|
import { ArrowLeft, Lock, Globe, FileText, Download } from "lucide-react";
|
||||||
import api from "@/utils/api.util";
|
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -30,31 +31,12 @@ export default function ViewDocumentAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
const [streamUrl, setStreamUrl] = useState(null);
|
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assetId) fetchAsset(assetId);
|
if (assetId) fetchAsset(assetId);
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
@@ -87,6 +69,9 @@ export default function ViewDocumentAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// modules/admin/pages/assets/ViewImageAsset.jsx
|
// modules/admin/pages/assets/ViewImageAsset.jsx
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import api from "@/utils/api.util";
|
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
|
||||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -28,31 +29,12 @@ export default function ViewImageAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
const [streamUrl, setStreamUrl] = useState(null);
|
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assetId) fetchAsset(assetId);
|
if (assetId) fetchAsset(assetId);
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
}
|
}
|
||||||
@@ -80,6 +62,9 @@ export default function ViewImageAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
// modules/admin/pages/assets/ViewVideoAsset.jsx
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeft, Lock, Globe } from "lucide-react";
|
import { ArrowLeft, Lock, Globe, Download } from "lucide-react";
|
||||||
import api from "@/utils/api.util";
|
|
||||||
import { useDateFormat } from "@/hooks/useDateFormat";
|
import { useDateFormat } from "@/hooks/useDateFormat";
|
||||||
|
|
||||||
import { useAssets } from "@/contexts/AdminAssetsContext";
|
import { useAssets } from "@/contexts/AdminAssetsContext";
|
||||||
|
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
|
||||||
|
import { downloadAsset } from "@/utils/media.util";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -38,31 +39,12 @@ export default function ViewVideoAsset() {
|
|||||||
const { fmtDateTime } = useDateFormat();
|
const { fmtDateTime } = useDateFormat();
|
||||||
|
|
||||||
const { selectedAsset, loading, fetchAsset } = useAssets();
|
const { selectedAsset, loading, fetchAsset } = useAssets();
|
||||||
const [streamUrl, setStreamUrl] = useState(null);
|
const { src: streamUrl } = useAssetPreviewSrc(selectedAsset, { scope: "admin" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (assetId) fetchAsset(assetId);
|
if (assetId) fetchAsset(assetId);
|
||||||
}, [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) {
|
if (loading) {
|
||||||
return <MediaFallback className="h-96 rounded-lg" />;
|
return <MediaFallback className="h-96 rounded-lg" />;
|
||||||
}
|
}
|
||||||
@@ -90,6 +72,9 @@ export default function ViewVideoAsset() {
|
|||||||
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
<h1 className="text-xl font-semibold truncate">{a.display_name ?? a.original_name}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
<p className="text-sm text-muted-foreground">{a.mime_type}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => downloadAsset(a, { scope: "admin" })}>
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
import { formatDate } from '@/utils/table.util';
|
import { formatDate } from '@/utils/table.util';
|
||||||
import { formatBytes } from './blocks/FileUpload';
|
import { formatBytes } from './blocks/FileUpload';
|
||||||
import api from '@/utils/api.util';
|
import api from '@/utils/api.util';
|
||||||
|
import { saveBlob } from '@/utils/media.util';
|
||||||
import FileZoomViewer from './FileZoomViewer';
|
import FileZoomViewer from './FileZoomViewer';
|
||||||
|
|
||||||
// ─── Resolve preview kind from mime type ──────────────────────────────────────
|
// ─── 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 ────────────────────────────────────────────────────────────────
|
// ─── Main dialog ────────────────────────────────────────────────────────────────
|
||||||
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
|
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
|
||||||
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
|
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
|
||||||
@@ -223,7 +212,7 @@ const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
|
|||||||
setDownloading(true);
|
setDownloading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(url, { responseType: 'blob' });
|
const res = await api.get(url, { responseType: 'blob' });
|
||||||
downloadBlob(res.data, file.file_name);
|
saveBlob(res.data, file.file_name);
|
||||||
} catch {
|
} catch {
|
||||||
// fall back to direct link if proxy fails (e.g. public file_url)
|
// fall back to direct link if proxy fails (e.g. public file_url)
|
||||||
if (file.file_url) window.open(file.file_url, '_blank');
|
if (file.file_url) window.open(file.file_url, '_blank');
|
||||||
|
|||||||
+85
-5
@@ -1,13 +1,21 @@
|
|||||||
// utils/media.util.js
|
// utils/media.util.js
|
||||||
//
|
//
|
||||||
// Resolves a displayable <img> src for an asset payload that may carry a
|
// Resolves a displayable <img>/<video>/<audio> src for an asset payload that
|
||||||
// short-lived stream_token (private/S3 — proxied through the backend) instead
|
// may carry a short-lived stream_token (private/S3 — proxied through the
|
||||||
// of a raw file_url. Private assets must never expose their real storage URL
|
// backend) instead of a raw file_url. Private assets must never expose their
|
||||||
// to the browser — see controllers/admin/assets.controller.js's redactS3Url
|
// real storage URL to the browser — see controllers/admin/assets.controller.js's
|
||||||
// pattern, mirrored for advertisements in controllers/*/advertisements.controller.js.
|
// redactS3Url pattern, mirrored for advertisements in
|
||||||
|
// controllers/*/advertisements.controller.js.
|
||||||
|
|
||||||
|
import api from "@/utils/api.util";
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
const API_BASE = (import.meta.env.VITE_API_URL ?? "http://localhost:3024").replace(/\/$/, "");
|
||||||
|
|
||||||
|
const TOKEN_ENDPOINTS = {
|
||||||
|
admin: "/admin/media/token",
|
||||||
|
client: "/client/media/token",
|
||||||
|
};
|
||||||
|
|
||||||
export function streamUrl(token) {
|
export function streamUrl(token) {
|
||||||
return token ? `${API_BASE}/client/media/stream/${token}` : null;
|
return token ? `${API_BASE}/client/media/stream/${token}` : null;
|
||||||
}
|
}
|
||||||
@@ -20,3 +28,75 @@ export function resolveAssetSrc(asset) {
|
|||||||
if (asset.stream_token) return streamUrl(asset.stream_token);
|
if (asset.stream_token) return streamUrl(asset.stream_token);
|
||||||
return asset.thumbnail_url || asset.file_url || null;
|
return asset.thumbnail_url || asset.file_url || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── fetchAssetPreviewSrc ──────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Async fallback for when resolveAssetSrc() can't resolve synchronously (S3
|
||||||
|
// asset whose stream_token wasn't embedded on the row — e.g. an expired cache
|
||||||
|
// edge case). Mints a fresh token via the admin or client media-token endpoint.
|
||||||
|
// Non-S3 assets (chibisafe/local) resolve straight to file_url — no request.
|
||||||
|
//
|
||||||
|
// Returns { src, thumbnailUrl }.
|
||||||
|
export async function fetchAssetPreviewSrc(asset, { scope = "admin" } = {}) {
|
||||||
|
if (!asset) return { src: null, thumbnailUrl: null };
|
||||||
|
|
||||||
|
if (asset.storage_provider !== "s3") {
|
||||||
|
return { src: asset.file_url ?? null, thumbnailUrl: asset.thumbnail_url ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.stream_token) {
|
||||||
|
return { src: streamUrl(asset.stream_token), thumbnailUrl: asset.thumbnail_url ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await api.post(TOKEN_ENDPOINTS[scope] ?? TOKEN_ENDPOINTS.admin, {
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
});
|
||||||
|
const { token, thumbnail_url } = data?.data ?? {};
|
||||||
|
return { src: streamUrl(token), thumbnailUrl: thumbnail_url ?? null };
|
||||||
|
} catch {
|
||||||
|
return { src: null, thumbnailUrl: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── saveBlob ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Triggers a browser "Save As" for an in-memory blob via a synthetic <a download>
|
||||||
|
// click. Shared by downloadAsset() below and FilePreview.jsx's file-completion
|
||||||
|
// download flow — kept in one place instead of being copy-pasted per consumer.
|
||||||
|
export function saveBlob(blob, filename) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename ?? "download";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── downloadAsset ──────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Fetches an asset's bytes (via the same proxy/file_url resolution as above)
|
||||||
|
// and saves it locally, without ever mounting a preview element. Used for
|
||||||
|
// "Public" assets in the Assets admin surfaces, which are download-only —
|
||||||
|
// see AssetPickerSheet.jsx / the admin ViewXAsset pages.
|
||||||
|
export async function downloadAsset(asset, { scope = "admin" } = {}) {
|
||||||
|
if (!asset) return;
|
||||||
|
|
||||||
|
const filename = asset.display_name ?? asset.original_name ?? "download";
|
||||||
|
let src = resolveAssetSrc(asset);
|
||||||
|
if (!src) {
|
||||||
|
({ src } = await fetchAssetPreviewSrc(asset, { scope }));
|
||||||
|
}
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data: blob } = await api.get(src, { responseType: "blob" });
|
||||||
|
saveBlob(blob, filename);
|
||||||
|
} catch {
|
||||||
|
// Cross-origin CDN (chibisafe/public file_url) may reject the blob
|
||||||
|
// fetch — fall back to opening it directly.
|
||||||
|
if (asset.file_url) window.open(asset.file_url, "_blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user