@flamingo-stack/openframe-frontend-core 0.0.387 → 0.0.388
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-DU2X2MH2.js → chunk-23BJ64AY.js} +2 -2
- package/dist/{chunk-OGNKGDHT.js → chunk-35C3PUUN.js} +4 -4
- package/dist/{chunk-RUHE3RJK.cjs → chunk-6ZAQOYW4.cjs} +62 -67
- package/dist/chunk-6ZAQOYW4.cjs.map +1 -0
- package/dist/{chunk-MVY4ZKQR.js → chunk-7CEJO3FE.js} +2 -2
- package/dist/{chunk-EHVRW3WB.js → chunk-7UQ74ZJN.js} +2 -2
- package/dist/{chunk-EULRZYQH.js → chunk-ATKRYY4U.js} +9 -14
- package/dist/chunk-ATKRYY4U.js.map +1 -0
- package/dist/{chunk-SOAWTQXO.cjs → chunk-B4JRV4FB.cjs} +31 -28
- package/dist/chunk-B4JRV4FB.cjs.map +1 -0
- package/dist/{chunk-GPTS2NXW.js → chunk-BGYWTU2E.js} +12 -9
- package/dist/chunk-BGYWTU2E.js.map +1 -0
- package/dist/{chunk-3BPVTHOW.js → chunk-DM3JIOFA.js} +4 -4
- package/dist/{chunk-XGLZK25V.js → chunk-EQKTBIKH.js} +2 -2
- package/dist/{chunk-SGULN3RX.cjs → chunk-EYQESL6X.cjs} +7 -7
- package/dist/{chunk-SGULN3RX.cjs.map → chunk-EYQESL6X.cjs.map} +1 -1
- package/dist/{chunk-N2LW5NHS.js → chunk-FS4FVNOU.js} +2 -2
- package/dist/{chunk-OLCNA32C.cjs → chunk-HIODHJCJ.cjs} +15 -15
- package/dist/{chunk-OLCNA32C.cjs.map → chunk-HIODHJCJ.cjs.map} +1 -1
- package/dist/{chunk-44ENRZZJ.cjs → chunk-HQG5AY64.cjs} +12 -12
- package/dist/{chunk-44ENRZZJ.cjs.map → chunk-HQG5AY64.cjs.map} +1 -1
- package/dist/{chunk-3L4B7MBD.cjs → chunk-KYN2GYUI.cjs} +31 -31
- package/dist/{chunk-3L4B7MBD.cjs.map → chunk-KYN2GYUI.cjs.map} +1 -1
- package/dist/{chunk-CIH2LZI7.cjs → chunk-ONXN77KP.cjs} +62 -62
- package/dist/{chunk-CIH2LZI7.cjs.map → chunk-ONXN77KP.cjs.map} +1 -1
- package/dist/{chunk-OX75ZFN6.js → chunk-SENYQRPF.js} +2 -2
- package/dist/{chunk-SECF2KWQ.cjs → chunk-SNFSLYVA.cjs} +5 -5
- package/dist/{chunk-SECF2KWQ.cjs.map → chunk-SNFSLYVA.cjs.map} +1 -1
- package/dist/{chunk-3KA5L7LC.js → chunk-UCQWQ7LM.js} +2 -2
- package/dist/{chunk-ANPV7GZT.js → chunk-VCRUAKOJ.js} +5701 -5281
- package/dist/chunk-VCRUAKOJ.js.map +1 -0
- package/dist/{chunk-DKN425XR.cjs → chunk-VOPTCR5I.cjs} +1604 -1184
- package/dist/chunk-VOPTCR5I.cjs.map +1 -0
- package/dist/{chunk-DO7LOWFE.cjs → chunk-WIKFWION.cjs} +9 -9
- package/dist/{chunk-DO7LOWFE.cjs.map → chunk-WIKFWION.cjs.map} +1 -1
- package/dist/{chunk-ADEP6E3Z.cjs → chunk-YKPQINSZ.cjs} +26 -26
- package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-YKPQINSZ.cjs.map} +1 -1
- package/dist/{chunk-POJRURML.cjs → chunk-ZVFDJ35S.cjs} +37 -37
- package/dist/{chunk-POJRURML.cjs.map → chunk-ZVFDJ35S.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/entity-video-section.d.ts +15 -3
- package/dist/components/features/entity-video-section.d.ts.map +1 -1
- package/dist/components/features/index.cjs +8 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +2 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +11 -5
- package/dist/components/features/video-bites-display.d.ts +12 -26
- package/dist/components/features/video-bites-display.d.ts.map +1 -1
- package/dist/components/features/video-bites-shared.cjs +24 -0
- package/dist/components/features/video-bites-shared.cjs.map +1 -0
- package/dist/components/features/video-bites-shared.d.ts +47 -0
- package/dist/components/features/video-bites-shared.d.ts.map +1 -0
- package/dist/components/features/video-bites-shared.js +20 -0
- package/dist/components/features/video-bites-shared.js.map +1 -0
- package/dist/components/features/video-bites-strip.d.ts +75 -0
- package/dist/components/features/video-bites-strip.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +6 -9
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +19 -0
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +128 -165
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +333 -370
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/shared/product-release/release-detail-page.d.ts +6 -7
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/square-avatar.d.ts +3 -1
- package/dist/components/square-avatar.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/components/user-display.d.ts +5 -1
- package/dist/components/user-display.d.ts.map +1 -1
- package/dist/index.cjs +8 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -5
- package/dist/types/case-study.d.ts +18 -0
- package/dist/types/case-study.d.ts.map +1 -1
- package/dist/types/video-processing.d.ts +11 -0
- package/dist/types/video-processing.d.ts.map +1 -1
- package/package.json +7 -1
- package/src/components/features/entity-video-section.tsx +26 -5
- package/src/components/features/index.ts +6 -3
- package/src/components/features/video-bites-display.tsx +17 -189
- package/src/components/features/video-bites-shared.ts +62 -0
- package/src/components/features/video-bites-strip.tsx +494 -0
- package/src/components/features/video-ratio-tabs.tsx +15 -19
- package/src/components/features/video.tsx +134 -3
- package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +10 -9
- package/src/components/shared/product-release/release-detail-page.tsx +12 -18
- package/src/components/square-avatar.tsx +5 -2
- package/src/components/user-display.tsx +14 -4
- package/src/stories/VideoBitesStrip.stories.tsx +82 -0
- package/src/types/case-study.ts +23 -0
- package/src/types/video-processing.ts +11 -0
- package/dist/chunk-ANPV7GZT.js.map +0 -1
- package/dist/chunk-DKN425XR.cjs.map +0 -1
- package/dist/chunk-EULRZYQH.js.map +0 -1
- package/dist/chunk-GPTS2NXW.js.map +0 -1
- package/dist/chunk-RUHE3RJK.cjs.map +0 -1
- package/dist/chunk-SOAWTQXO.cjs.map +0 -1
- /package/dist/{chunk-DU2X2MH2.js.map → chunk-23BJ64AY.js.map} +0 -0
- /package/dist/{chunk-OGNKGDHT.js.map → chunk-35C3PUUN.js.map} +0 -0
- /package/dist/{chunk-MVY4ZKQR.js.map → chunk-7CEJO3FE.js.map} +0 -0
- /package/dist/{chunk-EHVRW3WB.js.map → chunk-7UQ74ZJN.js.map} +0 -0
- /package/dist/{chunk-3BPVTHOW.js.map → chunk-DM3JIOFA.js.map} +0 -0
- /package/dist/{chunk-XGLZK25V.js.map → chunk-EQKTBIKH.js.map} +0 -0
- /package/dist/{chunk-N2LW5NHS.js.map → chunk-FS4FVNOU.js.map} +0 -0
- /package/dist/{chunk-OX75ZFN6.js.map → chunk-SENYQRPF.js.map} +0 -0
- /package/dist/{chunk-3KA5L7LC.js.map → chunk-UCQWQ7LM.js.map} +0 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <VideoBitesStrip> — THE unified public video-bites surface (Figma
|
|
5
|
+
* "Hero Section" node 4033-90364). Replaces the old aspect-ratio grid
|
|
6
|
+
* (`<VideoBitesDisplay>` is now a deprecated shim over this).
|
|
7
|
+
*
|
|
8
|
+
* One horizontal strip of uniform-height cards:
|
|
9
|
+
* - continuous marquee auto-scroll (rAF over native `scrollLeft` so the
|
|
10
|
+
* position stays scrubbable for chevrons + mid-track pause; the CSS
|
|
11
|
+
* keyframe engine in `quick-action-marquee.tsx` can't do either — we
|
|
12
|
+
* reuse ONLY its WCAG clone pattern: track rendered twice, clone copy
|
|
13
|
+
* `aria-hidden` with no focusable descendants)
|
|
14
|
+
* - hover = bottom-docked detail overlay + muted chromeless autoplay
|
|
15
|
+
* preview + marquee pause
|
|
16
|
+
* - profile / description-slot / navigation injection via props
|
|
17
|
+
* - the `<Video>` Mux SSoT is the only player primitive used
|
|
18
|
+
*
|
|
19
|
+
* Perf contract: each card renders the REAL player (first frame + center
|
|
20
|
+
* play control — every card looks and behaves like any other video surface)
|
|
21
|
+
* while the card is within ~500px of the viewport, via a TWO-WAY
|
|
22
|
+
* IntersectionObserver: players mount as cards approach and UNMOUNT again
|
|
23
|
+
* once the marquee carries them far off-screen. Live player count is
|
|
24
|
+
* therefore bounded by the visible strip width (+margin), not by list
|
|
25
|
+
* length or clone count. Playback itself starts only on hover
|
|
26
|
+
* (`<Video playOnHover>` — sound at 50%, muted fallback).
|
|
27
|
+
*
|
|
28
|
+
* Marquee state model (explicit — do not "simplify" into one timer):
|
|
29
|
+
* the rAF advances only when the pause-reason set {hover, offViewport,
|
|
30
|
+
* tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
|
|
31
|
+
* userScrollSuppressUntil)`. Chevron clicks and manual wheel/touch each set
|
|
32
|
+
* their own suppress-until timestamp; the player leave-grace is a separate
|
|
33
|
+
* concern that never touches marquee state.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
37
|
+
import { cn } from '../../utils/cn';
|
|
38
|
+
import { Video } from './video';
|
|
39
|
+
import { useVideoWarmup } from './use-video-warmup';
|
|
40
|
+
import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
|
|
41
|
+
import type { VideoTeaserWithRatio } from './video-ratio-tabs';
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_VIDEO_BITES_TITLE,
|
|
44
|
+
sortBitesByCreatedAtDesc,
|
|
45
|
+
toStripProfile,
|
|
46
|
+
type VideoBiteStripProfile,
|
|
47
|
+
} from './video-bites-shared';
|
|
48
|
+
import { UserDisplay } from '../user-display';
|
|
49
|
+
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
50
|
+
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
51
|
+
import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
|
|
52
|
+
|
|
53
|
+
// NOTE: the title constant / profile adapter / sort comparator live in the
|
|
54
|
+
// server-safe leaf `video-bites-shared.ts` (its own published subpath). The
|
|
55
|
+
// features barrel exports both modules — do NOT re-export the leaf from here
|
|
56
|
+
// (duplicate `export *` names make the barrel exports ambiguous).
|
|
57
|
+
|
|
58
|
+
// =============================================================================
|
|
59
|
+
// Types
|
|
60
|
+
// =============================================================================
|
|
61
|
+
|
|
62
|
+
/** Extends VideoTeaserWithRatio — aspect_ratio is INHERITED, never re-declared. */
|
|
63
|
+
export interface VideoBiteStripItem extends VideoTeaserWithRatio {
|
|
64
|
+
/** Per-bite profile override (falls back to the section-level `profile`). */
|
|
65
|
+
profile?: VideoBiteStripProfile | null;
|
|
66
|
+
/** Navigation target — overlay chevron renders an anchor. */
|
|
67
|
+
href?: string;
|
|
68
|
+
/** Navigation callback — used when `href` is absent (overlay chevron = button). */
|
|
69
|
+
onNavigate?: () => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface VideoBitesStripProps {
|
|
73
|
+
bites: ReadonlyArray<VideoBiteStripItem>;
|
|
74
|
+
/** Section heading. Default: DEFAULT_VIDEO_BITES_TITLE ('Key Moments'). */
|
|
75
|
+
title?: string;
|
|
76
|
+
showTitle?: boolean;
|
|
77
|
+
/** Filter to `published === true` bites. Default true. */
|
|
78
|
+
filterPublished?: boolean;
|
|
79
|
+
/** Section-level profile applied to bites without their own. */
|
|
80
|
+
profile?: VideoBiteStripProfile | null;
|
|
81
|
+
/** Section-level navigation target applied to bites without their own
|
|
82
|
+
* `href` — the hover overlay links to the entity the bites originated from. */
|
|
83
|
+
href?: string;
|
|
84
|
+
/** Custom node rendered between the heading and the strip (description slot). */
|
|
85
|
+
headerSlot?: React.ReactNode;
|
|
86
|
+
/** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
|
|
87
|
+
autoScroll?: boolean;
|
|
88
|
+
/** Marquee speed in px/s. */
|
|
89
|
+
autoScrollSpeed?: number;
|
|
90
|
+
/** Pause the marquee while the pointer is anywhere over the strip. */
|
|
91
|
+
pauseOnHover?: boolean;
|
|
92
|
+
/** Card height in px per breakpoint (Figma: 416 desktop). */
|
|
93
|
+
cardHeightDesktop?: number;
|
|
94
|
+
cardHeightMobile?: number;
|
|
95
|
+
/** Floating prev/next buttons. Hidden automatically when nothing overflows. */
|
|
96
|
+
showChevrons?: boolean;
|
|
97
|
+
/** Section-level navigation fallback (per-bite `href`/`onNavigate` win). */
|
|
98
|
+
onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
|
|
99
|
+
className?: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Gap between cards (px) — keep in sync with the track's `gap-4`.
|
|
103
|
+
const TRACK_GAP_PX = 16;
|
|
104
|
+
// Marquee suppression windows after manual interaction.
|
|
105
|
+
const CHEVRON_SUPPRESS_MS = 4000;
|
|
106
|
+
const USER_SCROLL_SUPPRESS_MS = 3000;
|
|
107
|
+
|
|
108
|
+
// =============================================================================
|
|
109
|
+
// Component
|
|
110
|
+
// =============================================================================
|
|
111
|
+
|
|
112
|
+
export function VideoBitesStrip({
|
|
113
|
+
bites,
|
|
114
|
+
title = DEFAULT_VIDEO_BITES_TITLE,
|
|
115
|
+
showTitle = true,
|
|
116
|
+
filterPublished = true,
|
|
117
|
+
profile = null,
|
|
118
|
+
href,
|
|
119
|
+
headerSlot,
|
|
120
|
+
autoScroll = true,
|
|
121
|
+
autoScrollSpeed = 28,
|
|
122
|
+
pauseOnHover = true,
|
|
123
|
+
cardHeightDesktop = 416,
|
|
124
|
+
cardHeightMobile = 300,
|
|
125
|
+
showChevrons = true,
|
|
126
|
+
onBiteNavigate,
|
|
127
|
+
className,
|
|
128
|
+
}: VideoBitesStripProps): React.ReactElement | null {
|
|
129
|
+
const items = useMemo(() => {
|
|
130
|
+
const filtered = filterPublished ? bites.filter(b => b.published) : [...bites];
|
|
131
|
+
return filtered.sort(sortBitesByCreatedAtDesc);
|
|
132
|
+
}, [bites, filterPublished]);
|
|
133
|
+
|
|
134
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
135
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
136
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
137
|
+
|
|
138
|
+
// ---- environment ----------------------------------------------------------
|
|
139
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
140
|
+
const [isTouch, setIsTouch] = useState(false);
|
|
141
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const subs: Array<() => void> = [];
|
|
144
|
+
const watch = (query: string, set: (v: boolean) => void) => {
|
|
145
|
+
const mq = window.matchMedia(query);
|
|
146
|
+
set(mq.matches);
|
|
147
|
+
const onChange = (e: MediaQueryListEvent) => set(e.matches);
|
|
148
|
+
mq.addEventListener('change', onChange);
|
|
149
|
+
subs.push(() => mq.removeEventListener('change', onChange));
|
|
150
|
+
};
|
|
151
|
+
watch('(prefers-reduced-motion: reduce)', setReducedMotion);
|
|
152
|
+
watch('(hover: none)', setIsTouch);
|
|
153
|
+
watch('(max-width: 767px)', setIsMobile);
|
|
154
|
+
return () => subs.forEach(fn => fn());
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
// ---- overflow measurement ---------------------------------------------------
|
|
158
|
+
// `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
|
|
159
|
+
const [overflows, setOverflows] = useState(false);
|
|
160
|
+
const singleCopyWidthRef = useRef(0);
|
|
161
|
+
const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
|
|
162
|
+
|
|
163
|
+
const measure = useCallback(() => {
|
|
164
|
+
const scroller = scrollerRef.current;
|
|
165
|
+
const track = trackRef.current;
|
|
166
|
+
if (!scroller || !track) return;
|
|
167
|
+
// When clones are rendered the track is 2× one copy (+ one joining gap).
|
|
168
|
+
const copies = track.dataset.copies === '2' ? 2 : 1;
|
|
169
|
+
const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
|
|
170
|
+
singleCopyWidthRef.current = singleCopy;
|
|
171
|
+
setOverflows(singleCopy > scroller.clientWidth + 1);
|
|
172
|
+
}, []);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
measure();
|
|
176
|
+
const scroller = scrollerRef.current;
|
|
177
|
+
const track = trackRef.current;
|
|
178
|
+
if (!scroller || !track || typeof ResizeObserver === 'undefined') return;
|
|
179
|
+
const ro = new ResizeObserver(measure);
|
|
180
|
+
ro.observe(scroller);
|
|
181
|
+
ro.observe(track);
|
|
182
|
+
return () => ro.disconnect();
|
|
183
|
+
}, [measure, items.length, marqueeActive, isMobile]);
|
|
184
|
+
|
|
185
|
+
// ---- pause-reason set + suppress timestamps ---------------------------------
|
|
186
|
+
const hoveredRef = useRef(false);
|
|
187
|
+
const nearViewportRef = useRef(true);
|
|
188
|
+
const chevronSuppressUntilRef = useRef(0);
|
|
189
|
+
const userScrollSuppressUntilRef = useRef(0);
|
|
190
|
+
|
|
191
|
+
// Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
|
|
192
|
+
// should stop again when the strip scrolls far off-screen.
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
const el = wrapperRef.current;
|
|
195
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
196
|
+
const io = new IntersectionObserver(
|
|
197
|
+
entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
|
|
198
|
+
{ rootMargin: '200px' },
|
|
199
|
+
);
|
|
200
|
+
io.observe(el);
|
|
201
|
+
return () => io.disconnect();
|
|
202
|
+
}, []);
|
|
203
|
+
|
|
204
|
+
// ---- marquee rAF engine ------------------------------------------------------
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!marqueeActive) return;
|
|
207
|
+
const scroller = scrollerRef.current;
|
|
208
|
+
if (!scroller) return;
|
|
209
|
+
let raf = 0;
|
|
210
|
+
let last = performance.now();
|
|
211
|
+
const tick = (now: number) => {
|
|
212
|
+
const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
|
|
213
|
+
last = now;
|
|
214
|
+
const paused =
|
|
215
|
+
(pauseOnHover && hoveredRef.current) ||
|
|
216
|
+
!nearViewportRef.current ||
|
|
217
|
+
document.visibilityState === 'hidden' ||
|
|
218
|
+
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
219
|
+
if (!paused) {
|
|
220
|
+
scroller.scrollLeft += autoScrollSpeed * dt;
|
|
221
|
+
const half = singleCopyWidthRef.current;
|
|
222
|
+
if (half > 0 && scroller.scrollLeft >= half) {
|
|
223
|
+
scroller.scrollLeft -= half; // seamless wrap at the clone seam
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
raf = requestAnimationFrame(tick);
|
|
227
|
+
};
|
|
228
|
+
raf = requestAnimationFrame(tick);
|
|
229
|
+
return () => cancelAnimationFrame(raf);
|
|
230
|
+
}, [marqueeActive, autoScrollSpeed, pauseOnHover]);
|
|
231
|
+
|
|
232
|
+
// ---- hover / overlay state -----------------------------------------------------
|
|
233
|
+
// activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
|
|
234
|
+
// a clone near the wrap seam works too). It only drives OVERLAY visibility —
|
|
235
|
+
// the player is mounted per-card via near-viewport gating and plays on hover
|
|
236
|
+
// through <Video playOnHover> (normal controls, no chrome fork).
|
|
237
|
+
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
238
|
+
const activate = useCallback((key: string) => setActiveKey(key), []);
|
|
239
|
+
const deactivate = useCallback((key: string) => {
|
|
240
|
+
setActiveKey(current => (current === key ? null : current));
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
// Preconnect Mux/Supabase origins once so first hover starts fast.
|
|
244
|
+
const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
|
|
245
|
+
|
|
246
|
+
// ---- manual navigation -----------------------------------------------------------
|
|
247
|
+
const scrollByCard = useCallback((dir: 1 | -1) => {
|
|
248
|
+
const scroller = scrollerRef.current;
|
|
249
|
+
const track = trackRef.current;
|
|
250
|
+
if (!scroller || !track) return;
|
|
251
|
+
const firstCard = track.firstElementChild as HTMLElement | null;
|
|
252
|
+
const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
|
|
253
|
+
chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
|
|
254
|
+
scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
|
|
255
|
+
}, []);
|
|
256
|
+
|
|
257
|
+
const onUserScrollIntent = useCallback(() => {
|
|
258
|
+
userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
if (items.length === 0) return null;
|
|
262
|
+
|
|
263
|
+
const cardHeight = isMobile ? cardHeightMobile : cardHeightDesktop;
|
|
264
|
+
const copies = marqueeActive ? 2 : 1;
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
ref={node => { wrapperRef.current = node; warmup.ref(node); }}
|
|
269
|
+
className={cn('flex flex-col gap-6 w-full min-w-0', className)}
|
|
270
|
+
onPointerEnter={() => { hoveredRef.current = true; }}
|
|
271
|
+
onPointerLeave={() => { hoveredRef.current = false; }}
|
|
272
|
+
>
|
|
273
|
+
{showTitle && (
|
|
274
|
+
<h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
|
|
275
|
+
)}
|
|
276
|
+
{headerSlot}
|
|
277
|
+
|
|
278
|
+
<div className="relative">
|
|
279
|
+
<div
|
|
280
|
+
ref={scrollerRef}
|
|
281
|
+
className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
282
|
+
onWheel={onUserScrollIntent}
|
|
283
|
+
onTouchStart={onUserScrollIntent}
|
|
284
|
+
onPointerDown={onUserScrollIntent}
|
|
285
|
+
>
|
|
286
|
+
<div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
|
|
287
|
+
{Array.from({ length: copies }, (_, copyIndex) =>
|
|
288
|
+
items.map((bite, i) => {
|
|
289
|
+
const key = `${bite.url}__${copyIndex}__${i}`;
|
|
290
|
+
const isClone = copyIndex === 1;
|
|
291
|
+
return (
|
|
292
|
+
// Clone copy: aria-hidden + no focusable descendants (WCAG —
|
|
293
|
+
// same rule as quick-action-marquee). Clones stay pointer-
|
|
294
|
+
// interactive (briefly visible near the wrap seam).
|
|
295
|
+
<BiteStripCard
|
|
296
|
+
key={key}
|
|
297
|
+
bite={bite}
|
|
298
|
+
index={i}
|
|
299
|
+
cardKey={key}
|
|
300
|
+
isClone={isClone}
|
|
301
|
+
isTouch={isTouch}
|
|
302
|
+
height={cardHeight}
|
|
303
|
+
active={activeKey === key}
|
|
304
|
+
onActivate={activate}
|
|
305
|
+
onDeactivate={deactivate}
|
|
306
|
+
profile={bite.profile ?? profile ?? null}
|
|
307
|
+
sectionHref={href}
|
|
308
|
+
onBiteNavigate={onBiteNavigate}
|
|
309
|
+
/>
|
|
310
|
+
);
|
|
311
|
+
}),
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{showChevrons && overflows && (
|
|
317
|
+
<>
|
|
318
|
+
{/* Solid Figma button-icon look (bg #212121 = ods-card, border soft-grey). */}
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
aria-label="Previous videos"
|
|
322
|
+
onClick={() => scrollByCard(-1)}
|
|
323
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 p-3 rounded-md bg-ods-card border border-ods-border text-ods-text-primary hover:border-ods-accent transition-colors"
|
|
324
|
+
>
|
|
325
|
+
<Chevron02LeftIcon className="w-6 h-6" />
|
|
326
|
+
</button>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
aria-label="Next videos"
|
|
330
|
+
onClick={() => scrollByCard(1)}
|
|
331
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 p-3 rounded-md bg-ods-card border border-ods-border text-ods-text-primary hover:border-ods-accent transition-colors"
|
|
332
|
+
>
|
|
333
|
+
<Chevron02RightIcon className="w-6 h-6" />
|
|
334
|
+
</button>
|
|
335
|
+
</>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// =============================================================================
|
|
343
|
+
// Card (private)
|
|
344
|
+
// =============================================================================
|
|
345
|
+
|
|
346
|
+
interface BiteStripCardProps {
|
|
347
|
+
bite: VideoBiteStripItem;
|
|
348
|
+
index: number;
|
|
349
|
+
cardKey: string;
|
|
350
|
+
isClone: boolean;
|
|
351
|
+
isTouch: boolean;
|
|
352
|
+
height: number;
|
|
353
|
+
active: boolean;
|
|
354
|
+
onActivate: (key: string) => void;
|
|
355
|
+
onDeactivate: (key: string) => void;
|
|
356
|
+
profile: VideoBiteStripProfile | null;
|
|
357
|
+
sectionHref?: string;
|
|
358
|
+
onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function BiteStripCard({
|
|
362
|
+
bite,
|
|
363
|
+
index,
|
|
364
|
+
cardKey,
|
|
365
|
+
isClone,
|
|
366
|
+
isTouch,
|
|
367
|
+
height,
|
|
368
|
+
active,
|
|
369
|
+
onActivate,
|
|
370
|
+
onDeactivate,
|
|
371
|
+
profile,
|
|
372
|
+
sectionHref,
|
|
373
|
+
onBiteNavigate,
|
|
374
|
+
}: BiteStripCardProps) {
|
|
375
|
+
const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
|
|
376
|
+
const targetHref = bite.href ?? sectionHref;
|
|
377
|
+
const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
|
|
378
|
+
|
|
379
|
+
// TWO-WAY viewport gating (unlike the fire-once `useNearViewport`): the
|
|
380
|
+
// marquee cycles every card past the viewport, so a fire-once gate would
|
|
381
|
+
// permanently mount a player per card (originals + clones). This observer
|
|
382
|
+
// unmounts players again once the card scrolls >500px away, bounding live
|
|
383
|
+
// MuxPlayer instances to roughly the visible strip. Near cards render the
|
|
384
|
+
// REAL player (first frame + center play control); hover-preview comes
|
|
385
|
+
// from <Video playOnHover>.
|
|
386
|
+
const nearRef = useRef<HTMLDivElement | null>(null);
|
|
387
|
+
const [isNear, setIsNear] = useState(false);
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
const el = nearRef.current;
|
|
390
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
391
|
+
const io = new IntersectionObserver(
|
|
392
|
+
entries => setIsNear(entries[0]?.isIntersecting ?? false),
|
|
393
|
+
{ rootMargin: '500px' },
|
|
394
|
+
);
|
|
395
|
+
io.observe(el);
|
|
396
|
+
return () => io.disconnect();
|
|
397
|
+
}, []);
|
|
398
|
+
|
|
399
|
+
const navigate = () => {
|
|
400
|
+
if (bite.onNavigate) bite.onNavigate();
|
|
401
|
+
else onBiteNavigate?.(bite, index);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const handlePointerEnter = () => { if (!isTouch) onActivate(cardKey); };
|
|
405
|
+
const handlePointerLeave = () => { if (!isTouch) onDeactivate(cardKey); };
|
|
406
|
+
const handleClick = () => { if (isTouch && !active) onActivate(cardKey); };
|
|
407
|
+
|
|
408
|
+
// Bottom-docked detail (Figma node 4033:90369): title row + profile row +
|
|
409
|
+
// chevron affordance. The WHOLE footer is the navigation target — it links
|
|
410
|
+
// to the entity the bite originated from.
|
|
411
|
+
const overlayContent = (
|
|
412
|
+
<>
|
|
413
|
+
{bite.title && (
|
|
414
|
+
<p className="font-['DM_Sans'] text-sm font-medium leading-5 text-ods-text-primary line-clamp-1">{bite.title}</p>
|
|
415
|
+
)}
|
|
416
|
+
{(profile || hasTarget) && (
|
|
417
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
418
|
+
{profile && (
|
|
419
|
+
<UserDisplay
|
|
420
|
+
name={profile.name}
|
|
421
|
+
avatarUrl={profile.avatarUrl}
|
|
422
|
+
subtitle={profile.subtitle}
|
|
423
|
+
size={28}
|
|
424
|
+
shape="round"
|
|
425
|
+
compact
|
|
426
|
+
className="flex-1"
|
|
427
|
+
/>
|
|
428
|
+
)}
|
|
429
|
+
{hasTarget && (
|
|
430
|
+
<Chevron02RightIcon className="w-5 h-5 shrink-0 ml-auto text-ods-text-primary" />
|
|
431
|
+
)}
|
|
432
|
+
</div>
|
|
433
|
+
)}
|
|
434
|
+
</>
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// Figma node 4033:90369 exactly: pure-black 75% fill (Tailwind palette
|
|
438
|
+
// black — NOT a glassy backdrop blur; Figma has no background blur here),
|
|
439
|
+
// full soft-grey border, p-16/gap-16, large soft drop shadow.
|
|
440
|
+
const overlayClass = cn(
|
|
441
|
+
'absolute inset-x-0 bottom-0 p-3 gap-2 bg-black/75 border border-ods-border shadow-2xl',
|
|
442
|
+
'flex flex-col transition-opacity duration-200',
|
|
443
|
+
active ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
|
|
444
|
+
// Non-interactive while invisible so it never swallows clicks on the
|
|
445
|
+
// resting card / player controls.
|
|
446
|
+
active ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto',
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div
|
|
451
|
+
ref={nearRef}
|
|
452
|
+
aria-hidden={isClone || undefined}
|
|
453
|
+
className="relative shrink-0 rounded-md border border-ods-border bg-ods-card overflow-hidden group/card"
|
|
454
|
+
style={{ height, aspectRatio: cssAspect, maxWidth: '90vw' }}
|
|
455
|
+
onPointerEnter={handlePointerEnter}
|
|
456
|
+
onPointerLeave={handlePointerLeave}
|
|
457
|
+
onClick={handleClick}
|
|
458
|
+
onFocus={() => onActivate(cardKey)}
|
|
459
|
+
onBlur={() => onDeactivate(cardKey)}
|
|
460
|
+
>
|
|
461
|
+
{isNear ? (
|
|
462
|
+
<div className="absolute inset-0">
|
|
463
|
+
{/* Hover = auto-play WITH sound at 50% (muted fallback on autoplay
|
|
464
|
+
policy); chrome = center play/pause only (Figma card look). */}
|
|
465
|
+
<Video
|
|
466
|
+
kind="file"
|
|
467
|
+
url={bite.url}
|
|
468
|
+
poster={bite.thumbnail_url}
|
|
469
|
+
playOnHover
|
|
470
|
+
centerControlsOnly
|
|
471
|
+
layout="fill"
|
|
472
|
+
/>
|
|
473
|
+
</div>
|
|
474
|
+
) : (
|
|
475
|
+
// Aspect-matched placeholder until the card nears the viewport (CLS-free).
|
|
476
|
+
<div className="absolute inset-0 bg-ods-card" />
|
|
477
|
+
)}
|
|
478
|
+
|
|
479
|
+
{hasTarget && !isClone ? (
|
|
480
|
+
targetHref ? (
|
|
481
|
+
<a href={targetHref} aria-label={`Open ${bite.title || 'source content'}`} className={overlayClass}>
|
|
482
|
+
{overlayContent}
|
|
483
|
+
</a>
|
|
484
|
+
) : (
|
|
485
|
+
<button type="button" onClick={navigate} aria-label={`Open ${bite.title || 'source content'}`} className={cn(overlayClass, 'text-left w-full')}>
|
|
486
|
+
{overlayContent}
|
|
487
|
+
</button>
|
|
488
|
+
)
|
|
489
|
+
) : (
|
|
490
|
+
<div className={overlayClass}>{overlayContent}</div>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Aspect-ratio tab + grouping primitives for video
|
|
4
|
+
* Aspect-ratio tab + grouping primitives for video surfaces.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* `RatioTabs` + `RATIO_GRID_CLASS` are ADMIN-ONLY now (VideoBitesEditor,
|
|
7
|
+
* VideoLibraryGrid) — the public grid was replaced by `<VideoBitesStrip>`,
|
|
8
|
+
* which consumes only `detectAspectRatio` + `RATIO_TO_CSS_ASPECT` from here.
|
|
9
9
|
*
|
|
10
|
-
* Why these live here in the lib (not the hub):
|
|
11
|
-
*
|
|
12
|
-
* The hub's admin editors re-export from here.
|
|
10
|
+
* Why these live here in the lib (not the hub): the strip and the hub's
|
|
11
|
+
* admin editors share the ratio primitives, and the lib is the SSoT.
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import {
|
|
@@ -29,16 +28,13 @@ export type VizardAspectRatio = '9:16' | '16:9' | '1:1';
|
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* Extended VideoTeaser with aspect_ratio metadata from Vizard.
|
|
32
|
-
* The
|
|
33
|
-
*
|
|
34
|
-
* Import this when you need the ratio at compile time
|
|
31
|
+
* The base `VideoTeaser` carries every other persisted bite field
|
|
32
|
+
* (timing/confidence/viral_reason live there); this adds ONLY the
|
|
33
|
+
* ratio. Import this when you need the ratio at compile time —
|
|
34
|
+
* it is the canonical type display/aggregation code extends.
|
|
35
35
|
*/
|
|
36
36
|
export interface VideoTeaserWithRatio extends VideoTeaser {
|
|
37
37
|
aspect_ratio?: VizardAspectRatio;
|
|
38
|
-
confidence?: number;
|
|
39
|
-
viral_reason?: string;
|
|
40
|
-
start_time_ms?: number;
|
|
41
|
-
end_time_ms?: number;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
/** Ratio category used for grid layout and tab grouping. */
|
|
@@ -55,11 +51,11 @@ export const RATIO_GRID_CLASS: Record<RatioCategory, string> = {
|
|
|
55
51
|
landscape: 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
|
56
52
|
};
|
|
57
53
|
|
|
58
|
-
/**
|
|
59
|
-
export const
|
|
60
|
-
portrait: '
|
|
61
|
-
square: '
|
|
62
|
-
landscape: '
|
|
54
|
+
/** CSS `aspect-ratio` value per category — sizing for strip cards + placeholders. */
|
|
55
|
+
export const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {
|
|
56
|
+
portrait: '9 / 16',
|
|
57
|
+
square: '1 / 1',
|
|
58
|
+
landscape: '16 / 9',
|
|
63
59
|
};
|
|
64
60
|
|
|
65
61
|
const RATIO_TAB_CONFIG: { key: RatioCategory; label: string }[] = [
|