add: more things

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-07-04 07:27:30 +08:00
parent 536c644623
commit 054064b99e
6 changed files with 101 additions and 24 deletions
@@ -3,6 +3,8 @@ import { ImageIcon } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { AssetPickerSheet } from "../../AssetPickerSheet"; import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
function MediaPlaceholder({ onClick }) { function MediaPlaceholder({ onClick }) {
@@ -23,17 +25,27 @@ function MediaPlaceholder({ onClick }) {
export function ImageBlock({ content, onUpdate, readOnly = false }) { export function ImageBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — see
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "admin" },
);
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Label>Image</Label> <Label>Image</Label>
{content.url ? ( {loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div <div
className="relative rounded-lg overflow-hidden cursor-pointer group" className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
> >
<img <img
src={content.url} src={src}
alt={content.alt ?? ""} alt={content.alt ?? ""}
className="w-full aspect-video object-cover" className="w-full aspect-video object-cover"
/> />
@@ -45,7 +57,7 @@ export function ImageBlock({ content, onUpdate, readOnly = false }) {
<MediaPlaceholder onClick={() => setPickerOpen(true)} /> <MediaPlaceholder onClick={() => setPickerOpen(true)} />
)} )}
{content.url && ( {content.asset_id && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Alt Text</Label> <Label>Alt Text</Label>
<Input <Input
@@ -64,6 +76,7 @@ export function ImageBlock({ content, onUpdate, readOnly = false }) {
onSelect={(asset) => onUpdate({ onSelect={(asset) => onUpdate({
...content, ...content,
asset_id: asset.asset_id, asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url, url: asset.file_url,
})} })}
/> />
@@ -13,10 +13,19 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../../AssetPickerSheet"; import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) { export function TextImageBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "admin" },
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -54,13 +63,15 @@ export function TextImageBlock({ content, onUpdate, blockId, readOnly = false })
].join(" ")}> ].join(" ")}>
<Label>Image</Label> <Label>Image</Label>
{content.url ? ( {loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div <div
className="relative rounded-lg overflow-hidden cursor-pointer group" className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
> >
<img <img
src={content.url} src={src}
alt={content.alt ?? ""} alt={content.alt ?? ""}
className="w-full aspect-video object-cover" className="w-full aspect-video object-cover"
/> />
@@ -79,7 +90,7 @@ export function TextImageBlock({ content, onUpdate, blockId, readOnly = false })
</button> </button>
)} )}
{content.url && ( {content.asset_id && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>Alt Text</Label> <Label>Alt Text</Label>
<Input <Input
@@ -100,6 +111,7 @@ export function TextImageBlock({ content, onUpdate, blockId, readOnly = false })
onSelect={(asset) => onUpdate({ onSelect={(asset) => onUpdate({
...content, ...content,
asset_id: asset.asset_id, asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url, url: asset.file_url,
})} })}
/> />
@@ -12,11 +12,20 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { RichTextEditor } from "./TextBlock"; // reuse the shared editor import { RichTextEditor } from "./TextBlock"; // reuse the shared editor
import { AssetPickerSheet } from "../../AssetPickerSheet"; import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) { export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const thumb = content.thumbnail_url ?? null; // content.thumbnail_url is a presigned S3 URL captured at pick time for
// S3 assets — it expires. Re-resolve through media.util.js instead of
// trusting the persisted value, mirroring VideoBlock.jsx.
const { thumbnailUrl, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.thumbnail_url },
{ scope: "admin" },
);
const thumb = thumbnailUrl ?? content.thumbnail_url ?? null;
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -55,7 +64,9 @@ export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false })
].join(" ")}> ].join(" ")}>
<Label>Video</Label> <Label>Video</Label>
{content.url ? ( {loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : content.asset_id ? (
<div <div
className="relative rounded-lg overflow-hidden cursor-pointer group" className="relative rounded-lg overflow-hidden cursor-pointer group"
onClick={() => setPickerOpen(true)} onClick={() => setPickerOpen(true)}
@@ -101,6 +112,7 @@ export function TextVideoBlock({ content, onUpdate, blockId, readOnly = false })
onSelect={(asset) => onUpdate({ onSelect={(asset) => onUpdate({
...content, ...content,
asset_id: asset.asset_id, asset_id: asset.asset_id,
storage_provider: asset.storage_provider ?? null,
url: asset.file_url, url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null, thumbnail_url: asset.thumbnail_url ?? null,
})} })}
@@ -10,6 +10,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet"; import { AssetPickerSheet } from "../../AssetPickerSheet";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -25,6 +27,15 @@ const fmtTime = (s) => {
export function VideoBlock({ content, onUpdate, readOnly = false }) { export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
// content.url is redacted (null) server-side for S3 assets — see
// redactS3Url() in controllers/admin/assets.controller.js. Re-resolve
// through media.util.js instead of trusting the persisted url/thumbnail.
const { src, thumbnailUrl, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.thumbnail_url },
{ scope: "admin" },
);
const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined;
const vidRef = useRef(null); const vidRef = useRef(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
@@ -41,7 +52,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
setCurrentTime(0); setCurrentTime(0);
setTotalDuration(0); setTotalDuration(0);
setOverlayVisible(true); setOverlayVisible(true);
}, [content.url]); }, [src]);
// ── Video event listeners ───────────────────────────────────────────────── // ── Video event listeners ─────────────────────────────────────────────────
@@ -65,7 +76,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
v.removeEventListener("loadedmetadata", onLoaded); v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded); v.removeEventListener("ended", onEnded);
}; };
}, [content.url]); }, [src]);
// ── Controls ────────────────────────────────────────────────────────────── // ── Controls ──────────────────────────────────────────────────────────────
@@ -126,11 +137,12 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
// //
const handleSelect = (asset) => { const handleSelect = (asset) => {
onUpdate({ onUpdate({
asset_id: asset.asset_id, asset_id: asset.asset_id,
url: asset.file_url, storage_provider: asset.storage_provider ?? null,
thumbnail_url: asset.thumbnail_url ?? null, url: asset.file_url,
title: asset.display_name, thumbnail_url: asset.thumbnail_url ?? null,
tag: asset.extension?.toUpperCase() ?? "", title: asset.display_name,
tag: asset.extension?.toUpperCase() ?? "",
}); });
setPlaying(false); setPlaying(false);
setProgress(0); setProgress(0);
@@ -145,7 +157,9 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
<div className="space-y-3"> <div className="space-y-3">
{!readOnly && <Label>Video</Label>} {!readOnly && <Label>Video</Label>}
{content.url ? ( {loading ? (
<MediaFallback className="w-full aspect-video rounded-lg" />
) : src ? (
<div className="rounded-lg overflow-hidden bg-card"> <div className="rounded-lg overflow-hidden bg-card">
{/* ── Video area ── */} {/* ── Video area ── */}
@@ -157,8 +171,8 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
> >
<video <video
ref={vidRef} ref={vidRef}
src={content.url} src={src}
poster={content.thumbnail_url ?? undefined} poster={poster}
preload="metadata" preload="metadata"
playsInline playsInline
className="w-full h-full object-cover" className="w-full h-full object-cover"
@@ -1,8 +1,25 @@
import { ImageIcon } from "lucide-react"; import { ImageIcon } from "lucide-react";
import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview"; import { ZoomableImage } from "@/modules/admin/components/courses/LessonsPreview";
import { useAssetPreviewSrc } from "@/hooks/useAssetPreviewSrc";
import { MediaFallback } from "@/components/generic/MediaFallback";
// content shape: { asset_id?, url?, storage_provider?, alt? }
//
// url is redacted (null) server-side for S3 assets — see redactS3Url() in
// controllers/admin/assets.controller.js. Resolve through useAssetPreviewSrc
// (media.util.js) instead of reading content.url directly, so S3-backed
// images re-mint a fresh stream token/thumbnail at render time.
export function ImageBlock({ content }) { export function ImageBlock({ content }) {
if (!content.url) { const { src, loading } = useAssetPreviewSrc(
{ asset_id: content?.asset_id, storage_provider: content?.storage_provider, file_url: content?.url, thumbnail_url: content?.url },
{ scope: "client" },
);
if (loading) {
return <MediaFallback className="aspect-video rounded-lg" />;
}
if (!src) {
return ( return (
<div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5"> <div className="flex items-center justify-center aspect-video rounded-lg border border-dashed text-xs text-muted-foreground bg-muted/20 gap-1.5">
<ImageIcon className="h-4 w-4" /> <ImageIcon className="h-4 w-4" />
@@ -11,5 +28,5 @@ export function ImageBlock({ content }) {
); );
} }
return <ZoomableImage url={content.url} alt={content.alt} />; return <ZoomableImage url={src} alt={content?.alt} />;
} }
@@ -165,6 +165,7 @@ export function VideoBlock({ content }) {
// ── Stream state ────────────────────────────────────────────────────────── // ── Stream state ──────────────────────────────────────────────────────────
const [blobUrl, setBlobUrl] = useState(null); const [blobUrl, setBlobUrl] = useState(null);
const [freshThumb, setFreshThumb] = useState(null);
const [fetchLoading, setFetchLoading] = useState(false); const [fetchLoading, setFetchLoading] = useState(false);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
@@ -193,7 +194,11 @@ export function VideoBlock({ content }) {
const assetId = content?.asset_id; const assetId = content?.asset_id;
const storageProvider = content?.storage_provider; const storageProvider = content?.storage_provider;
const poster = content?.thumbnail_url ?? undefined; // content.thumbnail_url is a presigned S3 URL captured at CMS pick time —
// it expires. For S3 assets, use the fresh thumbnail_url minted alongside
// the stream token below instead; only fall back to the persisted value
// for chibisafe (stable public CDN URL).
const poster = (storageProvider === "s3" ? freshThumb : content?.thumbnail_url) ?? undefined;
// ── Resolve stream URL → set as video src directly ─────────────────────── // ── Resolve stream URL → set as video src directly ───────────────────────
// //
@@ -206,6 +211,7 @@ export function VideoBlock({ content }) {
if (!assetId) return; if (!assetId) return;
setBlobUrl(null); setBlobUrl(null);
setFreshThumb(null);
setFetchError(false); setFetchError(false);
setPlaying(false); setPlaying(false);
setProgress(0); setProgress(0);
@@ -231,10 +237,13 @@ export function VideoBlock({ content }) {
// S3 — get token then stream directly; no blob download // S3 — get token then stream directly; no blob download
const { data } = await api.post("/client/media/token", { asset_id: assetId }); const { data } = await api.post("/client/media/token", { asset_id: assetId });
if (cancelled) return; if (cancelled) return;
const { token } = data?.data ?? {}; const { token, thumbnail_url } = data?.data ?? {};
if (!token) throw new Error("No token returned"); if (!token) throw new Error("No token returned");
if (!cancelled) setBlobUrl(`${API_BASE}/client/media/stream/${token}`); if (!cancelled) {
setBlobUrl(`${API_BASE}/client/media/stream/${token}`);
if (thumbnail_url) setFreshThumb(thumbnail_url);
}
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;