ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
@@ -0,0 +1,280 @@
import { useRef, useState, useEffect, useCallback } from "react";
import {
Play,
Pause,
SkipBack,
Volume2,
VolumeX,
Maximize2,
VideoIcon,
} from "lucide-react";
import { Label } from "@/components/ui/label";
import { AssetPickerSheet } from "../../AssetPickerSheet";
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtTime = (s) => {
if (!s || isNaN(s)) return "0:00";
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec < 10 ? "0" : ""}${sec}`;
};
// ─── VideoBlock (Admin) ───────────────────────────────────────────────────────
export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [pickerOpen, setPickerOpen] = useState(false);
const vidRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [overlayVisible,setOverlayVisible]= useState(true);
// Reset player when video changes
useEffect(() => {
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
}, [content.url]);
// ── Video event listeners ─────────────────────────────────────────────────
useEffect(() => {
const v = vidRef.current;
if (!v) return;
const onTimeUpdate = () => {
setCurrentTime(v.currentTime);
if (v.duration) setProgress((v.currentTime / v.duration) * 100);
};
const onLoaded = () => setTotalDuration(v.duration);
const onEnded = () => { setPlaying(false); setOverlayVisible(true); };
v.addEventListener("timeupdate", onTimeUpdate);
v.addEventListener("loadedmetadata", onLoaded);
v.addEventListener("ended", onEnded);
return () => {
v.removeEventListener("timeupdate", onTimeUpdate);
v.removeEventListener("loadedmetadata", onLoaded);
v.removeEventListener("ended", onEnded);
};
}, [content.url]);
// ── Controls ──────────────────────────────────────────────────────────────
const togglePlay = useCallback(() => {
const v = vidRef.current;
if (!v) return;
if (v.paused) { v.play(); setPlaying(true); setOverlayVisible(false); }
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
const restart = () => {
const v = vidRef.current;
if (!v) return;
v.currentTime = 0;
v.pause();
setPlaying(false);
setOverlayVisible(true);
};
const handleSeek = (e) => {
const v = vidRef.current;
if (!v || !v.duration) return;
v.currentTime = (parseFloat(e.target.value) / 100) * v.duration;
};
const handleVolumeChange = (e) => {
const val = parseFloat(e.target.value);
setVolume(val);
if (vidRef.current) { vidRef.current.volume = val; vidRef.current.muted = val === 0; }
setMuted(val === 0);
};
const toggleMute = () => {
const v = vidRef.current;
if (!v) return;
v.muted = !v.muted;
setMuted(v.muted);
};
const toggleFullscreen = () => {
const el = document.getElementById("video-block-wrap");
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen();
else el.requestFullscreen?.();
};
// ── Asset picker handler ──────────────────────────────────────────────────
//
// Saves all metadata needed by the client VideoBlock at render time.
// Clean object — no ...content spread so stale data never carries over.
//
// Fields saved:
// asset_id — used by client for the secure token flow
// url — used by admin player (direct src); ignored by client for S3
// thumbnail_url — poster image for the video player
// title — display name (shown in any title-aware blocks)
// tag — file extension badge e.g. "MP4"
//
const handleSelect = (asset) => {
onUpdate({
asset_id: asset.asset_id,
url: asset.file_url,
thumbnail_url: asset.thumbnail_url ?? null,
title: asset.display_name,
tag: asset.extension?.toUpperCase() ?? "",
});
setPlaying(false);
setProgress(0);
setCurrentTime(0);
setTotalDuration(0);
setOverlayVisible(true);
};
// ─────────────────────────────────────────────────────────────────────────
return (
<div className="space-y-3">
{!readOnly && <Label>Video</Label>}
{content.url ? (
<div className="rounded-lg overflow-hidden bg-card">
{/* ── Video area ── */}
<div
id="video-block-wrap"
className="relative w-full bg-black cursor-pointer group"
style={{ aspectRatio: "16/9" }}
onClick={togglePlay}
>
<video
ref={vidRef}
src={content.url}
poster={content.thumbnail_url ?? undefined}
preload="metadata"
playsInline
className="w-full h-full object-cover"
/>
{/* Play/pause overlay */}
<div
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${overlayVisible ? "opacity-100" : "opacity-0 pointer-events-none"}`}
style={{ background: "rgba(0,0,0,0.3)" }}
>
<button
aria-label={playing ? "Pause" : "Play"}
onClick={(e) => { e.stopPropagation(); togglePlay(); }}
className="w-12 h-12 rounded-full bg-white/90 hover:bg-white flex items-center justify-center transition-transform hover:scale-105"
>
{playing
? <Pause className="size-4 text-black" />
: <Play className="size-4 text-black ml-0.5" />
}
</button>
</div>
{/* Change video hover hint */}
{!readOnly && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
className="text-xs bg-black/60 hover:bg-black/80 text-white px-2.5 py-1 rounded-md transition-colors"
onClick={(e) => { e.stopPropagation(); setPickerOpen(true); }}
>
Change
</button>
</div>
)}
</div>
{/* ── Player controls ── */}
<div className="px-3 pt-2.5 pb-3 flex flex-col gap-2">
{/* Progress bar */}
<div className="relative h-1 bg-border rounded-full cursor-pointer">
<div
className="h-full bg-foreground rounded-full transition-[width] duration-100"
style={{ width: `${progress}%` }}
/>
<input
type="range" min="0" max="100" step="0.1"
value={progress}
onChange={handleSeek}
aria-label="Seek"
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</div>
{/* Button row */}
<div className="flex items-center gap-2">
<button onClick={togglePlay} aria-label={playing ? "Pause" : "Play"} className="text-muted-foreground hover:text-foreground transition-colors">
{playing ? <Pause className="size-4" /> : <Play className="size-4" />}
</button>
<button onClick={restart} aria-label="Restart" className="text-muted-foreground hover:text-foreground transition-colors">
<SkipBack className="size-4" />
</button>
<span className="text-xs text-muted-foreground tabular-nums">
{fmtTime(currentTime)} / {fmtTime(totalDuration)}
</span>
<div className="flex items-center gap-1.5 ml-auto">
<button onClick={toggleMute} aria-label="Toggle mute" className="text-muted-foreground hover:text-foreground transition-colors">
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
</button>
<input
type="range" min="0" max="1" step="0.05"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
aria-label="Volume"
className="w-16 accent-foreground"
/>
</div>
<button onClick={toggleFullscreen} aria-label="Fullscreen" className="text-muted-foreground hover:text-foreground transition-colors ml-1">
<Maximize2 className="size-4" />
</button>
</div>
</div>
{/* ── Change video footer (admin only) ── */}
{!readOnly && (
<div className="px-3 pb-3">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full text-sm text-muted-foreground border rounded-md py-1.5 hover:bg-muted transition-colors"
>
Change video
</button>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-full aspect-video rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-primary/50 hover:bg-muted/20 transition-colors flex flex-col items-center justify-center gap-2"
>
<VideoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">Click to select a video</p>
</button>
)}
{!readOnly && (
<AssetPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
fileType="video"
onSelect={handleSelect}
/>
)}
</div>
);
}