mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
@@ -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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -127,6 +138,7 @@ 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,
|
||||||
|
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,
|
||||||
title: asset.display_name,
|
title: asset.display_name,
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user