client revised

This commit is contained in:
rgrgogu
2026-08-05 04:32:29 +08:00
parent cd16e996e5
commit 882dfbe67a
52 changed files with 1946 additions and 2302 deletions
@@ -211,7 +211,7 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
}}
/>
) : (
<div className="absolute inset-0 bg-muted" />
<div className="absolute inset-0 bg-primary" />
)}
<div className="relative z-10 flex items-center gap-4 p-4 text-white">
@@ -291,7 +291,9 @@ export function AudioBlock({ content, onUpdate, readOnly = false }) {
value={muted ? 0 : volume}
onChange={handleVolume}
aria-label="Volume"
className="w-16 h-1.5"
className="w-16 h-1.5 accent-primary cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
/>
</div>
@@ -31,6 +31,7 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
const poster = thumbnailUrl ?? content?.thumbnail_url ?? undefined;
const vidRef = useRef(null);
const wrapRef = useRef(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
@@ -38,7 +39,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(1);
const [overlayVisible,setOverlayVisible]= useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
// custom controls instead of falling back to native <video> fullscreen.
const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || pseudoFullscreen;
// True from the moment `src` is set until the browser has actually
// buffered enough to render a frame (or stalls mid-playback) — closes the
// gap between "token resolved" (the `loading` from useAssetPreviewSrc
@@ -104,6 +111,13 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
// Safari doesn't focus a <div tabIndex> on click, which silently kills
// keyboard shortcuts afterwards — force it explicitly.
const handleAreaClick = useCallback(() => {
wrapRef.current?.focus({ preventScroll: true });
togglePlay();
}, [togglePlay]);
const restart = () => {
const v = vidRef.current;
if (!v) return;
@@ -134,10 +148,24 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
};
const toggleFullscreen = () => {
const el = document.getElementById("video-block-wrap");
// Uses wrapRef instead of getElementById so this works correctly even if
// an admin page ever renders more than one VideoBlock at once (an id
// lookup would always grab the first match, toggling the wrong player).
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen();
else el.requestFullscreen?.();
if (isFullscreen) {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
else setPseudoFullscreen(false);
} else if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
// Desktop Safari — supports element fullscreen via the prefixed API.
el.webkitRequestFullscreen();
} else {
// iOS Safari has no element-level Fullscreen API at all.
setPseudoFullscreen(true);
}
};
// Ignore keystrokes aimed at the seek/volume range inputs so arrow keys
@@ -145,7 +173,10 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
const v = vidRef.current;
switch (e.key) {
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
switch (key) {
case " ":
e.preventDefault();
togglePlay();
@@ -166,20 +197,49 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
e.preventDefault();
toggleFullscreen();
break;
case "Escape":
if (isFullscreen) {
e.preventDefault();
toggleFullscreen();
}
break;
default: break;
}
}, [togglePlay]);
}, [togglePlay, toggleMute, toggleFullscreen, isFullscreen]);
// #video-block-wrap now wraps both the video area and the controls bar
// below it (previously just the video), so fullscreen no longer drops the
// seek bar / volume / fullscreen button. isFullscreen also relaxes the
// 16/9 + max-height constraints so the video fills the screen properly.
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
// event for element-level fullscreen, so both are listened for.
const onChange = () => {
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
setNativeFullscreen(active);
// Safari doesn't focus a <div tabIndex> on click, so keyboard
// shortcuts silently stop working after entering fullscreen unless we
// explicitly refocus the wrapper here.
if (active) wrapRef.current?.focus({ preventScroll: true });
};
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
}, []);
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
// reclaim keyboard focus while it's active, restore on exit/unmount.
useEffect(() => {
if (!pseudoFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
wrapRef.current?.focus({ preventScroll: true });
return () => { document.body.style.overflow = prevOverflow; };
}, [pseudoFullscreen]);
// ── Asset picker handler ──────────────────────────────────────────────────
//
// Saves all metadata needed by the client VideoBlock at render time.
@@ -221,16 +281,17 @@ export function VideoBlock({ content, onUpdate, readOnly = false }) {
) : src ? (
<div
id="video-block-wrap"
ref={wrapRef}
tabIndex={0}
onKeyDown={handleKeyDown}
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 flex flex-col" : "rounded-lg border"}`}
className={`overflow-hidden bg-card outline-none focus-visible:ring-2 focus-visible:ring-ring ${isFullscreen ? "fixed inset-0 z-[100] flex flex-col" : "rounded-lg border"}`}
>
{/* ── Video area ── */}
<div
className={`relative w-full bg-black cursor-pointer group ${isFullscreen ? "flex-1 min-h-0" : ""}`}
style={isFullscreen ? undefined : { aspectRatio: "16/9", maxHeight: "calc(100vh - 260px)" }}
onClick={togglePlay}
onClick={handleAreaClick}
>
<video
ref={vidRef}
@@ -25,13 +25,16 @@ function hasLandingPage(ad) {
* Banner advertisement carousel — same carousel shell as Hero, so a placement
* with several live ads (e.g. tier_plans.banner) rotates through all of them
* instead of only ever showing the single highest-priority one.
* Each slide's layout is driven by its own content_mode:
* "content" — decorative panel with badge/headline/description/CTAs
* "image" — full-bleed image, clickable through to the ad's redirect/CTA
* Every current-shape ad (content_mode "content") always shows its image with
* badge/headline/description layered on top, same as Hero — the whole slide
* is clickable through to the ad's redirect_link/landing page whenever it has
* no CTA buttons of its own. content_mode "image" is legacy-only (pre-dates
* the mandatory badge/headline/description/image/link shape) and keeps
* rendering as a bare clickable image with no text overlay.
*
* Props:
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-banner click on image-only ads.
* ads — array of advertisement objects { content_mode, badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
*/
export function Banner({ ads, onCtaClick }) {
const [api, setApi] = useState();
@@ -63,12 +66,16 @@ export function Banner({ ads, onCtaClick }) {
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
// Whole-slide click-through only makes sense when there's no CTA
// row of its own to carry that responsibility instead — a landing
// page (Page Builder content) always wins over the plain redirect.
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
return (
<CarouselItem key={ad.advertisement_id}>
{ad.content_mode !== "content" ? (
// ── Full Image ──
// A landing page (Page Builder content) always wins over the CTA
// click-through — it's the only destination this ad actually has.
// ── Full Image (legacy Image Only ads with no badge/headline/description) ──
<button
type="button"
onClick={() => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, ctas[0]))}
@@ -85,21 +92,41 @@ export function Banner({ ads, onCtaClick }) {
)}
</button>
) : (
// ── Content + Image ──
<div className="tier_plans_banner w-full rounded-xl xs:p-5 sm:p-4 lg:p-10 text-white">
<div className="relative w-full items-start flex justify-between">
// ── Image + Content overlay ──
<div
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? handleSlideClick : undefined}
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
className={"relative w-full rounded-xl overflow-hidden border text-left h-[clamp(180px,22vw,320px)]" + (clickable ? " cursor-pointer" : "")}
>
{imageSrc ? (
<img
src={imageSrc}
alt={ad.headline || "Advertisement"}
className="absolute inset-0 w-full h-full object-cover pointer-events-none select-none"
/>
) : (
<div className="absolute inset-0 bg-muted flex items-center justify-center">
<Megaphone className="size-6 text-muted-foreground" />
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/50 to-transparent" />
<div className="relative h-full w-full flex items-center xs:p-5 sm:p-4 lg:p-10 text-white overflow-hidden">
<div className="md:w-2xl space-y-4 xs:p-1.5 lg:p-0">
{badgeLabels.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{badgeLabels.map((label, i) => (
<Badge key={i} variant="outline" className="border-white/50 bg-white/10 text-white">
<Badge key={i} variant="outline" className="pointer-events-none select-none border-white/50 bg-white/10 text-white">
{label}
</Badge>
))}
</div>
)}
{ad.headline && <div className="lg:text-5xl xs:text-4xl font-tier-ads lg:leading-16">{ad.headline}</div>}
{ad.description && <p className="leading-relaxed text-lg">{ad.description}</p>}
{ad.headline && <div className="pointer-events-none select-none lg:text-5xl xs:text-4xl font-geist font-bold lg:leading-16">{ad.headline}</div>}
{ad.description && <p className="pointer-events-none select-none leading-relaxed text-lg line-clamp-2">{ad.description}</p>}
{ctas.length > 0 && (
<div className="flex gap-3 items-center">
@@ -115,17 +142,6 @@ export function Banner({ ads, onCtaClick }) {
</div>
)}
</div>
<div className="lg:absolute lg:-top-4 lg:-right-4">
<Button
variant="secondary"
aria-label={hasLandingPage(ad) ? "View advertisement details" : "Advertisement"}
className={hasLandingPage(ad) ? undefined : "pointer-events-none"}
onClick={() => hasLandingPage(ad) && setViewAd(ad)}
>
View <Megaphone />
</Button>
</div>
</div>
</div>
)}
@@ -25,12 +25,14 @@ function hasLandingPage(ad) {
/**
* Hero advertisement carousel.
* Each slide is a full-bleed background image with a bottom gradient overlay,
* badge/headline/description/CTAs anchored bottom-left. Renders null when no
* ads are given — callers should not fall back to placeholder copy.
* badge/headline/description/CTAs anchored bottom-left. The whole slide is
* clickable through to the ad's redirect_link/landing page whenever it has no
* CTA buttons of its own. Renders null when no ads are given — callers should
* not fall back to placeholder copy.
*
* Props:
* ads — array of advertisement objects { badge_labels, headline, description, ctas, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void, called when any CTA button is clicked
* ads — array of advertisement objects { badge_labels, headline, description, ctas, redirect_link, image, image_url, advertisement_id }
* onCtaClick — (ad, cta) => void. cta is undefined for the whole-slide click.
*/
export function Hero({ ads, onCtaClick }) {
const [api, setApi] = useState();
@@ -62,10 +64,22 @@ export function Hero({ ads, onCtaClick }) {
const ctas = Array.isArray(ad.ctas) ? ad.ctas : [];
const badgeLabels = Array.isArray(ad.badge_labels) ? ad.badge_labels : [];
// Whole-slide click-through only makes sense when there's no CTA
// row of its own to carry that responsibility instead — a landing
// page (Page Builder content) always wins over the plain redirect.
const clickable = ctas.length === 0 && (hasLandingPage(ad) || ad.redirect_link);
const handleSlideClick = () => (hasLandingPage(ad) ? setViewAd(ad) : onCtaClick?.(ad, undefined));
return (
<CarouselItem key={ad.advertisement_id}>
<Card className="border rounded-2xl overflow-hidden pl-0 py-0">
<CardContent className="relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center">
<CardContent
role={clickable ? "button" : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? handleSlideClick : undefined}
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") handleSlideClick(); } : undefined}
className={"relative xs:h-64 lg:h-96 bg-muted flex items-center justify-center" + (clickable ? " cursor-pointer" : "")}
>
{imageSrc ? (
<img
src={imageSrc}
@@ -254,7 +254,7 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
}}
/>
) : (
<div className="absolute inset-0 bg-muted" />
<div className="absolute inset-0 bg-primary" />
)}
{/* Mobile */}
@@ -334,7 +334,15 @@ export function AudioBlock({ content, onWatchProgress, resumePercent, antiSkipEn
<button onClick={toggleMute} aria-label={muted ? "Unmute" : "Mute"} className="w-7 h-7 flex items-center justify-center rounded-full text-foreground hover:bg-muted transition-colors">
{muted || volume === 0 ? <VolumeOff className="size-4" /> : <Volume2 className="size-4" />}
</button>
<input type="range" min="0" max="1" step="0.05" value={muted ? 0 : volume} onChange={handleVolume} aria-label="Volume" className="w-16 h-1.5" />
<input
type="range" min="0" max="1" step="0.05"
value={muted ? 0 : volume}
onChange={handleVolume}
aria-label="Volume"
className="w-16 h-1.5 accent-primary cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer
[&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-pointer"
/>
</div>
{/* Center — play controls */}
@@ -177,7 +177,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
const [ended, setEnded] = useState(false);
const [overlayVisible, setOverlayVisible] = useState(true);
const [controlsVisible, setControlsVisible] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
// Native Fullscreen API works on desktop (all browsers) and Android Chrome.
// iOS Safari doesn't support Fullscreen API on arbitrary elements at all, so
// pseudoFullscreen is a CSS-only (fixed inset-0) fallback that keeps our
// custom controls instead of falling back to native <video> fullscreen.
const [nativeFullscreen, setNativeFullscreen] = useState(false);
const [pseudoFullscreen, setPseudoFullscreen] = useState(false);
const isFullscreen = nativeFullscreen || pseudoFullscreen;
const [settingsOpen, setSettingsOpen] = useState(false);
const [volumePanelOpen, setVolumePanelOpen] = useState(false);
const [keyFeedback, setKeyFeedback] = useState(null);
@@ -343,11 +349,34 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
}, [speed]);
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
// Safari (incl. desktop) still fires the prefixed webkitfullscreenchange
// event for element-level fullscreen, so both are listened for.
const onChange = () => {
const active = !!(document.fullscreenElement || document.webkitFullscreenElement);
setNativeFullscreen(active);
// Safari doesn't focus a <div tabIndex> on click, so keyboard
// shortcuts silently stop working after entering fullscreen unless
// we explicitly refocus the wrapper here.
if (active) wrapRef.current?.focus({ preventScroll: true });
};
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
document.addEventListener("webkitfullscreenchange", onChange);
return () => {
document.removeEventListener("fullscreenchange", onChange);
document.removeEventListener("webkitfullscreenchange", onChange);
};
}, []);
// CSS pseudo-fullscreen fallback (iOS Safari) — lock page scroll and
// reclaim keyboard focus while it's active, restore on exit/unmount.
useEffect(() => {
if (!pseudoFullscreen) return;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
wrapRef.current?.focus({ preventScroll: true });
return () => { document.body.style.overflow = prevOverflow; };
}, [pseudoFullscreen]);
const resetHideTimer = useCallback(() => {
setControlsVisible(true);
clearTimeout(hideTimer.current);
@@ -377,6 +406,19 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
else { v.pause(); setPlaying(false); setOverlayVisible(true); }
}, []);
// YouTube-style tap-to-reveal: while playing with the bar auto-hidden, the
// first tap only brings the controls back (doesn't pause); a second tap —
// once controls are already visible — toggles play, same as desktop where
// onMouseMove already keeps controls visible ahead of the click.
const handleWrapperClick = useCallback(() => {
// Safari doesn't focus a <div tabIndex> on click, which silently kills
// keyboard shortcuts afterwards — force it explicitly.
wrapRef.current?.focus({ preventScroll: true });
if (!controlsVisible) { resetHideTimer(); return; }
togglePlay();
resetHideTimer();
}, [controlsVisible, resetHideTimer, togglePlay]);
const restart = () => {
const v = vidRef.current;
if (!v) return;
@@ -409,13 +451,27 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
const toggleFullscreen = () => {
const el = wrapRef.current;
if (!el) return;
if (document.fullscreenElement) document.exitFullscreen();
else el.requestFullscreen?.();
if (isFullscreen) {
if (document.fullscreenElement) document.exitFullscreen();
else if (document.webkitFullscreenElement) document.webkitExitFullscreen();
else setPseudoFullscreen(false);
} else if (el.requestFullscreen) {
el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
// Desktop Safari — supports element fullscreen via the prefixed API.
el.webkitRequestFullscreen();
} else {
// iOS Safari has no element-level Fullscreen API at all.
setPseudoFullscreen(true);
}
};
const handleKeyDown = useCallback((e) => {
if (e.target.tagName === "INPUT") return;
switch (e.key) {
// Fold single-char keys to lowercase so Shift/Caps-Lock doesn't break
// letter shortcuts (e.g. Shift+F reporting as "F", not "f").
const key = e.key.length === 1 ? e.key.toLowerCase() : e.key;
switch (key) {
case " ": case "k":
e.preventDefault();
togglePlay();
@@ -460,6 +516,13 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
toggleFullscreen();
showFeedback(isFullscreen ? <Minimize2 className="size-7 text-white" /> : <Maximize2 className="size-7 text-white" />, isFullscreen ? "Exit fullscreen" : "Fullscreen");
break;
case "Escape":
if (isFullscreen) {
e.preventDefault();
toggleFullscreen();
showFeedback(<Minimize2 className="size-7 text-white" />, "Exit fullscreen");
}
break;
default: break;
}
}, [togglePlay, toggleMute, toggleFullscreen, resetHideTimer, volume, muted, isFullscreen, playing, showFeedback, guard]);
@@ -492,11 +555,11 @@ export function VideoBlock({ content, onWatchProgress, resumePercent, antiSkipEn
<div
ref={wrapRef}
tabIndex={0}
className="relative w-full rounded-lg overflow-hidden bg-black select-none outline-none"
className={`relative w-full overflow-hidden bg-black select-none outline-none ${pseudoFullscreen ? "fixed inset-0 z-[100]" : "rounded-lg"}`}
style={{ aspectRatio: isFullscreen ? undefined : "16/9" }}
onMouseMove={resetHideTimer}
onMouseLeave={() => { if (playing) setControlsVisible(false); }}
onClick={() => { togglePlay(); resetHideTimer(); }}
onClick={handleWrapperClick}
onKeyDown={handleKeyDown}
onContextMenu={(e) => e.preventDefault()}
>