@flamingo-stack/openframe-frontend-core 0.0.387 → 0.0.388-snapshot.20260709030118
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-SOAWTQXO.cjs → chunk-2JCSORCJ.cjs} +31 -28
- package/dist/chunk-2JCSORCJ.cjs.map +1 -0
- package/dist/{chunk-OLCNA32C.cjs → chunk-3JQUNKWY.cjs} +15 -15
- package/dist/{chunk-OLCNA32C.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
- package/dist/{chunk-OX75ZFN6.js → chunk-44Q3IXZM.js} +2 -2
- package/dist/{chunk-MVY4ZKQR.js → chunk-4OLIDOLO.js} +2 -2
- package/dist/{chunk-ADEP6E3Z.cjs → chunk-7MKU2EB4.cjs} +26 -26
- package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
- package/dist/{chunk-EULRZYQH.js → chunk-BJPQJNSC.js} +9 -14
- package/dist/chunk-BJPQJNSC.js.map +1 -0
- package/dist/{chunk-XGLZK25V.js → chunk-CQXHHVMR.js} +2 -2
- package/dist/{chunk-POJRURML.cjs → chunk-EHTE3CTT.cjs} +37 -37
- package/dist/{chunk-POJRURML.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
- package/dist/{chunk-DKN425XR.cjs → chunk-GNB5ERZP.cjs} +1923 -1227
- package/dist/chunk-GNB5ERZP.cjs.map +1 -0
- package/dist/{chunk-ANPV7GZT.js → chunk-IDWKP5H5.js} +5974 -5278
- package/dist/chunk-IDWKP5H5.js.map +1 -0
- package/dist/{chunk-3KA5L7LC.js → chunk-JSFHTIKA.js} +2 -2
- package/dist/{chunk-GPTS2NXW.js → chunk-KA4WXCN3.js} +12 -9
- package/dist/chunk-KA4WXCN3.js.map +1 -0
- package/dist/{chunk-DU2X2MH2.js → chunk-LFLFQ422.js} +2 -2
- package/dist/{chunk-N2LW5NHS.js → chunk-M6ODI2F5.js} +2 -2
- package/dist/{chunk-DO7LOWFE.cjs → chunk-MIEODAAV.cjs} +9 -9
- package/dist/{chunk-DO7LOWFE.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
- package/dist/{chunk-44ENRZZJ.cjs → chunk-MPOP47ME.cjs} +12 -12
- package/dist/{chunk-44ENRZZJ.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
- package/dist/{chunk-EHVRW3WB.js → chunk-NULFIYK2.js} +2 -2
- package/dist/{chunk-CIH2LZI7.cjs → chunk-OBKCQ7LA.cjs} +62 -62
- package/dist/{chunk-CIH2LZI7.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
- package/dist/{chunk-3L4B7MBD.cjs → chunk-Q4YGG3CL.cjs} +31 -31
- package/dist/{chunk-3L4B7MBD.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
- package/dist/{chunk-SECF2KWQ.cjs → chunk-TQZLC3BQ.cjs} +5 -5
- package/dist/{chunk-SECF2KWQ.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
- package/dist/{chunk-RUHE3RJK.cjs → chunk-VXJBE5NX.cjs} +62 -67
- package/dist/chunk-VXJBE5NX.cjs.map +1 -0
- package/dist/{chunk-OGNKGDHT.js → chunk-WIMXB5EU.js} +4 -4
- package/dist/{chunk-3BPVTHOW.js → chunk-XGOX5UQ2.js} +4 -4
- package/dist/{chunk-SGULN3RX.cjs → chunk-ZFZAHLI4.cjs} +7 -7
- package/dist/{chunk-SGULN3RX.cjs.map → chunk-ZFZAHLI4.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 +10 -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 -3
- 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 +114 -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 +25 -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 +130 -165
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +333 -368
- 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 +10 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +11 -3
- 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 +739 -0
- package/src/components/features/video-ratio-tabs.tsx +15 -19
- package/src/components/features/video.tsx +265 -4
- 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-OX75ZFN6.js.map → chunk-44Q3IXZM.js.map} +0 -0
- /package/dist/{chunk-MVY4ZKQR.js.map → chunk-4OLIDOLO.js.map} +0 -0
- /package/dist/{chunk-XGLZK25V.js.map → chunk-CQXHHVMR.js.map} +0 -0
- /package/dist/{chunk-3KA5L7LC.js.map → chunk-JSFHTIKA.js.map} +0 -0
- /package/dist/{chunk-DU2X2MH2.js.map → chunk-LFLFQ422.js.map} +0 -0
- /package/dist/{chunk-N2LW5NHS.js.map → chunk-M6ODI2F5.js.map} +0 -0
- /package/dist/{chunk-EHVRW3WB.js.map → chunk-NULFIYK2.js.map} +0 -0
- /package/dist/{chunk-OGNKGDHT.js.map → chunk-WIMXB5EU.js.map} +0 -0
- /package/dist/{chunk-3BPVTHOW.js.map → chunk-XGOX5UQ2.js.map} +0 -0
|
@@ -0,0 +1,739 @@
|
|
|
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 {cardHovered, offViewport,
|
|
30
|
+
* tabHidden, reducedMotion} is empty AND `now > max(chevronSuppressUntil,
|
|
31
|
+
* userScrollSuppressUntil)`. cardHovered means the pointer is over a CARD
|
|
32
|
+
* (incl. its overlay) — leaving the card resumes the marquee immediately. Chevron clicks and manual wheel/touch each set
|
|
33
|
+
* their own suppress-until timestamp; the player leave-grace is a separate
|
|
34
|
+
* concern that never touches marquee state.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
38
|
+
import { cn } from '../../utils/cn';
|
|
39
|
+
import { Video } from './video';
|
|
40
|
+
import { useVideoWarmup } from './use-video-warmup';
|
|
41
|
+
import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
|
|
42
|
+
import type { VideoTeaserWithRatio } from './video-ratio-tabs';
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_VIDEO_BITES_TITLE,
|
|
45
|
+
sortBitesByCreatedAtDesc,
|
|
46
|
+
toStripProfile,
|
|
47
|
+
type VideoBiteStripProfile,
|
|
48
|
+
} from './video-bites-shared';
|
|
49
|
+
import { UserDisplay } from '../user-display';
|
|
50
|
+
import { SECTION_HEADING_CLASS } from '../layout/page-heading';
|
|
51
|
+
import { Chevron02LeftIcon } from '../icons-v2-generated/arrows/chevron-02-left-icon';
|
|
52
|
+
import { Chevron02RightIcon } from '../icons-v2-generated/arrows/chevron-02-right-icon';
|
|
53
|
+
|
|
54
|
+
// NOTE: the title constant / profile adapter / sort comparator live in the
|
|
55
|
+
// server-safe leaf `video-bites-shared.ts` (its own published subpath). The
|
|
56
|
+
// features barrel exports both modules — do NOT re-export the leaf from here
|
|
57
|
+
// (duplicate `export *` names make the barrel exports ambiguous).
|
|
58
|
+
|
|
59
|
+
// =============================================================================
|
|
60
|
+
// Types
|
|
61
|
+
// =============================================================================
|
|
62
|
+
|
|
63
|
+
/** Extends VideoTeaserWithRatio — aspect_ratio is INHERITED, never re-declared. */
|
|
64
|
+
export interface VideoBiteStripItem extends VideoTeaserWithRatio {
|
|
65
|
+
/** Per-bite profile override (falls back to the section-level `profile`). */
|
|
66
|
+
profile?: VideoBiteStripProfile | null;
|
|
67
|
+
/** Navigation target — overlay chevron renders an anchor. */
|
|
68
|
+
href?: string;
|
|
69
|
+
/** Navigation callback — used when `href` is absent (overlay chevron = button). */
|
|
70
|
+
onNavigate?: () => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface VideoBitesStripProps {
|
|
74
|
+
bites: ReadonlyArray<VideoBiteStripItem>;
|
|
75
|
+
/** Section heading. Default: DEFAULT_VIDEO_BITES_TITLE ('Key Moments'). */
|
|
76
|
+
title?: string;
|
|
77
|
+
showTitle?: boolean;
|
|
78
|
+
/** Filter to `published === true` bites. Default true. */
|
|
79
|
+
filterPublished?: boolean;
|
|
80
|
+
/** Section-level profile applied to bites without their own. */
|
|
81
|
+
profile?: VideoBiteStripProfile | null;
|
|
82
|
+
/** Section-level navigation target applied to bites without their own
|
|
83
|
+
* `href` — the hover overlay links to the entity the bites originated from. */
|
|
84
|
+
href?: string;
|
|
85
|
+
/** Custom node rendered between the heading and the strip (description slot). */
|
|
86
|
+
headerSlot?: React.ReactNode;
|
|
87
|
+
/** Marquee auto-scroll. Auto-disabled on no-overflow and prefers-reduced-motion. */
|
|
88
|
+
autoScroll?: boolean;
|
|
89
|
+
/** Marquee speed in px/s. Default 60 — marquee libraries (e.g. GSAP marquee)
|
|
90
|
+
* default around 100px/s; 60 keeps drift lively while cards stay easy to
|
|
91
|
+
* hover-target. 60px/s ≈ 1px per 60Hz frame, the smoothest integer step. */
|
|
92
|
+
autoScrollSpeed?: number;
|
|
93
|
+
/** Pause the marquee while a CARD is hovered (resumes as soon as the
|
|
94
|
+
* pointer leaves the card — strip whitespace/heading never pauses). */
|
|
95
|
+
pauseOnHover?: boolean;
|
|
96
|
+
/** Card height in px per breakpoint (Figma: 416 desktop). */
|
|
97
|
+
cardHeightDesktop?: number;
|
|
98
|
+
cardHeightMobile?: number;
|
|
99
|
+
/** Floating prev/next buttons. Hidden automatically when nothing overflows. */
|
|
100
|
+
showChevrons?: boolean;
|
|
101
|
+
/** Section-level navigation fallback (per-bite `href`/`onNavigate` win). */
|
|
102
|
+
onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
|
|
103
|
+
className?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Gap between cards (px) — keep in sync with the track's `gap-4`.
|
|
107
|
+
const TRACK_GAP_PX = 16;
|
|
108
|
+
// Marquee suppression windows after manual interaction.
|
|
109
|
+
const CHEVRON_SUPPRESS_MS = 4000;
|
|
110
|
+
const USER_SCROLL_SUPPRESS_MS = 3000;
|
|
111
|
+
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Component
|
|
114
|
+
// =============================================================================
|
|
115
|
+
|
|
116
|
+
export function VideoBitesStrip({
|
|
117
|
+
bites,
|
|
118
|
+
title = DEFAULT_VIDEO_BITES_TITLE,
|
|
119
|
+
showTitle = true,
|
|
120
|
+
filterPublished = true,
|
|
121
|
+
profile = null,
|
|
122
|
+
href,
|
|
123
|
+
headerSlot,
|
|
124
|
+
autoScroll = true,
|
|
125
|
+
autoScrollSpeed = 60,
|
|
126
|
+
pauseOnHover = true,
|
|
127
|
+
cardHeightDesktop = 416,
|
|
128
|
+
cardHeightMobile = 300,
|
|
129
|
+
showChevrons = true,
|
|
130
|
+
onBiteNavigate,
|
|
131
|
+
className,
|
|
132
|
+
}: VideoBitesStripProps): React.ReactElement | null {
|
|
133
|
+
const items = useMemo(() => {
|
|
134
|
+
const filtered = filterPublished ? bites.filter(b => b.published) : [...bites];
|
|
135
|
+
return filtered.sort(sortBitesByCreatedAtDesc);
|
|
136
|
+
}, [bites, filterPublished]);
|
|
137
|
+
|
|
138
|
+
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
139
|
+
const scrollerRef = useRef<HTMLDivElement | null>(null);
|
|
140
|
+
const trackRef = useRef<HTMLDivElement | null>(null);
|
|
141
|
+
|
|
142
|
+
// ---- environment ----------------------------------------------------------
|
|
143
|
+
const [reducedMotion, setReducedMotion] = useState(false);
|
|
144
|
+
const [isTouch, setIsTouch] = useState(false);
|
|
145
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const subs: Array<() => void> = [];
|
|
148
|
+
const watch = (query: string, set: (v: boolean) => void) => {
|
|
149
|
+
const mq = window.matchMedia(query);
|
|
150
|
+
set(mq.matches);
|
|
151
|
+
const onChange = (e: MediaQueryListEvent) => set(e.matches);
|
|
152
|
+
mq.addEventListener('change', onChange);
|
|
153
|
+
subs.push(() => mq.removeEventListener('change', onChange));
|
|
154
|
+
};
|
|
155
|
+
watch('(prefers-reduced-motion: reduce)', setReducedMotion);
|
|
156
|
+
watch('(hover: none)', setIsTouch);
|
|
157
|
+
watch('(max-width: 767px)', setIsMobile);
|
|
158
|
+
return () => subs.forEach(fn => fn());
|
|
159
|
+
}, []);
|
|
160
|
+
|
|
161
|
+
// ---- overflow measurement ---------------------------------------------------
|
|
162
|
+
// `overflows` gates chevrons; `marqueeActive` additionally gates cloning + rAF.
|
|
163
|
+
const [overflows, setOverflows] = useState(false);
|
|
164
|
+
const singleCopyWidthRef = useRef(0);
|
|
165
|
+
const marqueeActive = autoScroll && !reducedMotion && overflows && items.length > 0;
|
|
166
|
+
|
|
167
|
+
const measure = useCallback(() => {
|
|
168
|
+
const scroller = scrollerRef.current;
|
|
169
|
+
const track = trackRef.current;
|
|
170
|
+
if (!scroller || !track) return;
|
|
171
|
+
// When clones are rendered the track is 2× one copy (+ one joining gap).
|
|
172
|
+
const copies = track.dataset.copies === '2' ? 2 : 1;
|
|
173
|
+
const singleCopy = copies === 2 ? (track.scrollWidth - TRACK_GAP_PX) / 2 + TRACK_GAP_PX : track.scrollWidth;
|
|
174
|
+
singleCopyWidthRef.current = singleCopy;
|
|
175
|
+
setOverflows(singleCopy > scroller.clientWidth + 1);
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
measure();
|
|
180
|
+
const scroller = scrollerRef.current;
|
|
181
|
+
const track = trackRef.current;
|
|
182
|
+
if (!scroller || !track || typeof ResizeObserver === 'undefined') return;
|
|
183
|
+
const ro = new ResizeObserver(measure);
|
|
184
|
+
ro.observe(scroller);
|
|
185
|
+
ro.observe(track);
|
|
186
|
+
return () => ro.disconnect();
|
|
187
|
+
}, [measure, items.length, marqueeActive, isMobile]);
|
|
188
|
+
|
|
189
|
+
// ---- pause-reason set + suppress timestamps ---------------------------------
|
|
190
|
+
// (Card hover — the only hover-based pause reason — lives in activeKeyRef below.)
|
|
191
|
+
const nearViewportRef = useRef(true);
|
|
192
|
+
const chevronSuppressUntilRef = useRef(0);
|
|
193
|
+
const userScrollSuppressUntilRef = useRef(0);
|
|
194
|
+
|
|
195
|
+
// Two-way viewport gate (unlike the fire-once `useNearViewport`): the rAF
|
|
196
|
+
// should stop again when the strip scrolls far off-screen.
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const el = wrapperRef.current;
|
|
199
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
200
|
+
const io = new IntersectionObserver(
|
|
201
|
+
entries => { nearViewportRef.current = entries[0]?.isIntersecting ?? true; },
|
|
202
|
+
{ rootMargin: '200px' },
|
|
203
|
+
);
|
|
204
|
+
io.observe(el);
|
|
205
|
+
return () => io.disconnect();
|
|
206
|
+
}, []);
|
|
207
|
+
|
|
208
|
+
// ---- marquee rAF engine ------------------------------------------------------
|
|
209
|
+
// Position lives in a FLOAT ref, not in scrollLeft read-modify-write: reading
|
|
210
|
+
// scrollLeft back each frame returns a rounded value, so sub-pixel increments
|
|
211
|
+
// (speed/fps < 1px) get eaten by rounding — the old engine both stuttered AND
|
|
212
|
+
// ran measurably slower than the configured speed because of it.
|
|
213
|
+
//
|
|
214
|
+
// Chevron GLIDE also runs through this engine (glideRemainingRef): browser
|
|
215
|
+
// `scrollBy({behavior:'smooth'})` animates toward an ABSOLUTE target, so a
|
|
216
|
+
// seam warp mid-animation made it lunge a full copy-width to catch up, warp
|
|
217
|
+
// again, and oscillate — rapid chevron clicks left every card flickering.
|
|
218
|
+
// The rAF tween consumes a signed remaining-distance instead, wrapping
|
|
219
|
+
// modulo the copy width each frame, so warps are invisible to it.
|
|
220
|
+
const marqueePosRef = useRef(0);
|
|
221
|
+
const glideRemainingRef = useRef(0);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!marqueeActive) return;
|
|
224
|
+
const scroller = scrollerRef.current;
|
|
225
|
+
if (!scroller) return;
|
|
226
|
+
let raf = 0;
|
|
227
|
+
let last = performance.now();
|
|
228
|
+
marqueePosRef.current = scroller.scrollLeft;
|
|
229
|
+
const wrap = (pos: number) => {
|
|
230
|
+
const half = singleCopyWidthRef.current;
|
|
231
|
+
if (half <= 0) return pos;
|
|
232
|
+
while (pos >= half) pos -= half;
|
|
233
|
+
while (pos < 0) pos += half;
|
|
234
|
+
return pos;
|
|
235
|
+
};
|
|
236
|
+
const tick = (now: number) => {
|
|
237
|
+
const dt = Math.min(now - last, 100) / 1000; // clamp tab-wake jumps
|
|
238
|
+
last = now;
|
|
239
|
+
const paused =
|
|
240
|
+
(pauseOnHover && activeKeyRef.current !== null) ||
|
|
241
|
+
!nearViewportRef.current ||
|
|
242
|
+
document.visibilityState === 'hidden' ||
|
|
243
|
+
now < Math.max(chevronSuppressUntilRef.current, userScrollSuppressUntilRef.current);
|
|
244
|
+
const glide = glideRemainingRef.current;
|
|
245
|
+
if (glide !== 0) {
|
|
246
|
+
// Ease-out toward the chevron target; runs even while "paused"
|
|
247
|
+
// (the chevron click itself sets the suppress window).
|
|
248
|
+
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
249
|
+
marqueePosRef.current = scroller.scrollLeft;
|
|
250
|
+
}
|
|
251
|
+
const speed = Math.max(Math.abs(glide) * 6, 240); // px/s, proportional
|
|
252
|
+
const step = Math.sign(glide) * Math.min(Math.abs(glide), speed * dt);
|
|
253
|
+
glideRemainingRef.current = Math.abs(glide - step) < 0.5 ? 0 : glide - step;
|
|
254
|
+
marqueePosRef.current = wrap(marqueePosRef.current + step);
|
|
255
|
+
scroller.scrollLeft = marqueePosRef.current;
|
|
256
|
+
} else if (!paused) {
|
|
257
|
+
// Resync after external movement (user scroll, seam warp).
|
|
258
|
+
if (Math.abs(scroller.scrollLeft - marqueePosRef.current) > 1.5) {
|
|
259
|
+
marqueePosRef.current = scroller.scrollLeft;
|
|
260
|
+
}
|
|
261
|
+
marqueePosRef.current = wrap(marqueePosRef.current + autoScrollSpeed * dt);
|
|
262
|
+
scroller.scrollLeft = marqueePosRef.current;
|
|
263
|
+
} else {
|
|
264
|
+
marqueePosRef.current = scroller.scrollLeft;
|
|
265
|
+
}
|
|
266
|
+
raf = requestAnimationFrame(tick);
|
|
267
|
+
};
|
|
268
|
+
raf = requestAnimationFrame(tick);
|
|
269
|
+
return () => cancelAnimationFrame(raf);
|
|
270
|
+
}, [marqueeActive, autoScrollSpeed, pauseOnHover]);
|
|
271
|
+
|
|
272
|
+
// ---- hover / overlay state -----------------------------------------------------
|
|
273
|
+
// activeKey identifies the hovered/tap-activated CARD (copy-aware so hovering
|
|
274
|
+
// a clone near the wrap seam works too). It only drives OVERLAY visibility —
|
|
275
|
+
// the player is mounted per-card via near-viewport gating and plays on hover
|
|
276
|
+
// through <Video playOnHover> (normal controls, no chrome fork).
|
|
277
|
+
const [activeKey, setActiveKey] = useState<string | null>(null);
|
|
278
|
+
// Ref mirror for the rAF loop: the marquee pauses ONLY while a CARD is
|
|
279
|
+
// hovered (activeKey set) — hovering strip whitespace/heading does not
|
|
280
|
+
// pause, and leaving a card resumes immediately.
|
|
281
|
+
const activeKeyRef = useRef<string | null>(null);
|
|
282
|
+
const activate = useCallback((key: string) => {
|
|
283
|
+
activeKeyRef.current = key;
|
|
284
|
+
setActiveKey(key);
|
|
285
|
+
}, []);
|
|
286
|
+
const deactivate = useCallback((key: string) => {
|
|
287
|
+
setActiveKey(current => {
|
|
288
|
+
const next = current === key ? null : current;
|
|
289
|
+
activeKeyRef.current = next;
|
|
290
|
+
return next;
|
|
291
|
+
});
|
|
292
|
+
}, []);
|
|
293
|
+
|
|
294
|
+
// Preconnect Mux/Supabase origins once so first hover starts fast.
|
|
295
|
+
const warmup = useVideoWarmup<HTMLDivElement>({ videoUrl: items[0]?.url || null });
|
|
296
|
+
|
|
297
|
+
// ---- shared player-mount gate (by ITEM index, across both copies) -----------
|
|
298
|
+
// A card and its clone show identical content half a copy-width apart. If
|
|
299
|
+
// each card gated its own player, a seam warp instantly swapped the visible
|
|
300
|
+
// copy for cards whose players were NOT mounted yet — every wrap crossing
|
|
301
|
+
// flashed placeholders. Mounting by INDEX (near in EITHER copy → both copies
|
|
302
|
+
// mount) makes the warp pixel-identical. Live players stay bounded: ~2× the
|
|
303
|
+
// visible strip width.
|
|
304
|
+
const [mountedIdx, setMountedIdx] = useState<ReadonlySet<number>>(() => new Set());
|
|
305
|
+
const cardObserverRef = useRef<IntersectionObserver | null>(null);
|
|
306
|
+
const cardElIdxRef = useRef(new Map<Element, number>());
|
|
307
|
+
const cardElNearRef = useRef(new Map<Element, boolean>());
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
310
|
+
const io = new IntersectionObserver(entries => {
|
|
311
|
+
for (const e of entries) cardElNearRef.current.set(e.target, e.isIntersecting);
|
|
312
|
+
const next = new Set<number>();
|
|
313
|
+
cardElNearRef.current.forEach((near, el) => {
|
|
314
|
+
if (!near) return;
|
|
315
|
+
const idx = cardElIdxRef.current.get(el);
|
|
316
|
+
if (idx !== undefined) next.add(idx);
|
|
317
|
+
});
|
|
318
|
+
setMountedIdx(prev => {
|
|
319
|
+
if (prev.size === next.size) {
|
|
320
|
+
let same = true;
|
|
321
|
+
next.forEach(i => { if (!prev.has(i)) same = false; });
|
|
322
|
+
if (same) return prev;
|
|
323
|
+
}
|
|
324
|
+
return next;
|
|
325
|
+
});
|
|
326
|
+
}, { rootMargin: '500px' });
|
|
327
|
+
cardObserverRef.current = io;
|
|
328
|
+
cardElIdxRef.current.forEach((_idx, el) => io.observe(el));
|
|
329
|
+
return () => { io.disconnect(); cardObserverRef.current = null; };
|
|
330
|
+
}, []);
|
|
331
|
+
// Stable per-index ref callbacks (a fresh closure per render would detach/
|
|
332
|
+
// re-attach the observer every render). React 19 ref-cleanup unregisters.
|
|
333
|
+
const cardRefFnsRef = useRef(new Map<number, (el: HTMLDivElement | null) => (() => void) | undefined>());
|
|
334
|
+
const getCardRef = useCallback((idx: number) => {
|
|
335
|
+
let fn = cardRefFnsRef.current.get(idx);
|
|
336
|
+
if (!fn) {
|
|
337
|
+
fn = (el: HTMLDivElement | null) => {
|
|
338
|
+
if (!el) return undefined;
|
|
339
|
+
cardElIdxRef.current.set(el, idx);
|
|
340
|
+
cardObserverRef.current?.observe(el);
|
|
341
|
+
return () => {
|
|
342
|
+
cardElIdxRef.current.delete(el);
|
|
343
|
+
cardElNearRef.current.delete(el);
|
|
344
|
+
cardObserverRef.current?.unobserve(el);
|
|
345
|
+
};
|
|
346
|
+
};
|
|
347
|
+
cardRefFnsRef.current.set(idx, fn);
|
|
348
|
+
}
|
|
349
|
+
return fn;
|
|
350
|
+
}, []);
|
|
351
|
+
|
|
352
|
+
// ---- manual navigation -----------------------------------------------------------
|
|
353
|
+
const scrollByCard = useCallback((dir: 1 | -1) => {
|
|
354
|
+
const scroller = scrollerRef.current;
|
|
355
|
+
const track = trackRef.current;
|
|
356
|
+
if (!scroller || !track) return;
|
|
357
|
+
const firstCard = track.firstElementChild as HTMLElement | null;
|
|
358
|
+
const step = (firstCard?.offsetWidth ?? 320) + TRACK_GAP_PX;
|
|
359
|
+
chevronSuppressUntilRef.current = performance.now() + CHEVRON_SUPPRESS_MS;
|
|
360
|
+
if (marqueeActiveRef.current) {
|
|
361
|
+
// Wrap-aware glide via the rAF engine (see engine comment) — clicks
|
|
362
|
+
// accumulate distance instead of racing browser smooth-scroll targets.
|
|
363
|
+
glideRemainingRef.current += dir * step;
|
|
364
|
+
} else {
|
|
365
|
+
// No clones/seam without the marquee — native smooth scroll is safe.
|
|
366
|
+
scroller.scrollBy({ left: dir * step, behavior: 'smooth' });
|
|
367
|
+
}
|
|
368
|
+
}, []);
|
|
369
|
+
|
|
370
|
+
const onUserScrollIntent = useCallback(() => {
|
|
371
|
+
userScrollSuppressUntilRef.current = performance.now() + USER_SCROLL_SUPPRESS_MS;
|
|
372
|
+
}, []);
|
|
373
|
+
|
|
374
|
+
// Never-ending strip: warp across the clone seam on EVERY scroll (manual
|
|
375
|
+
// wheel/drag/chevron included — the rAF only wraps while the marquee runs,
|
|
376
|
+
// so without this a user scrolling during a pause/suppress window hits the
|
|
377
|
+
// physical track edges). Both copies render identical content, so a ±half
|
|
378
|
+
// jump is pixel-invisible. Backward warp only when arriving AT the left
|
|
379
|
+
// edge from the right (never on initial rest at 0).
|
|
380
|
+
const lastScrollLeftRef = useRef(0);
|
|
381
|
+
const marqueeActiveRef = useRef(false);
|
|
382
|
+
marqueeActiveRef.current = marqueeActive;
|
|
383
|
+
const onSeamWarp = useCallback(() => {
|
|
384
|
+
const scroller = scrollerRef.current;
|
|
385
|
+
if (!scroller || !marqueeActiveRef.current) return;
|
|
386
|
+
const half = singleCopyWidthRef.current;
|
|
387
|
+
if (half <= 0) return;
|
|
388
|
+
let sl = scroller.scrollLeft;
|
|
389
|
+
if (sl >= half) {
|
|
390
|
+
sl -= half;
|
|
391
|
+
scroller.scrollLeft = sl;
|
|
392
|
+
marqueePosRef.current = sl;
|
|
393
|
+
} else if (sl <= 0 && lastScrollLeftRef.current > 0.5) {
|
|
394
|
+
sl += half;
|
|
395
|
+
scroller.scrollLeft = sl;
|
|
396
|
+
marqueePosRef.current = sl;
|
|
397
|
+
}
|
|
398
|
+
lastScrollLeftRef.current = sl;
|
|
399
|
+
}, []);
|
|
400
|
+
|
|
401
|
+
if (items.length === 0) return null;
|
|
402
|
+
|
|
403
|
+
const cardHeight = isMobile ? cardHeightMobile : cardHeightDesktop;
|
|
404
|
+
const copies = marqueeActive ? 2 : 1;
|
|
405
|
+
|
|
406
|
+
return (
|
|
407
|
+
<div
|
|
408
|
+
ref={node => { wrapperRef.current = node; warmup.ref(node); }}
|
|
409
|
+
className={cn('flex flex-col gap-6 w-full min-w-0', className)}
|
|
410
|
+
>
|
|
411
|
+
{showTitle && (
|
|
412
|
+
<h2 className={`${SECTION_HEADING_CLASS} break-words`}>{title}</h2>
|
|
413
|
+
)}
|
|
414
|
+
{headerSlot}
|
|
415
|
+
|
|
416
|
+
<div className="relative">
|
|
417
|
+
<div
|
|
418
|
+
ref={scrollerRef}
|
|
419
|
+
className="overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
420
|
+
onWheel={onUserScrollIntent}
|
|
421
|
+
onTouchStart={onUserScrollIntent}
|
|
422
|
+
onPointerDown={onUserScrollIntent}
|
|
423
|
+
onScroll={onSeamWarp}
|
|
424
|
+
>
|
|
425
|
+
<div ref={trackRef} data-copies={copies} className="flex w-max items-stretch gap-4">
|
|
426
|
+
{Array.from({ length: copies }, (_, copyIndex) =>
|
|
427
|
+
items.map((bite, i) => {
|
|
428
|
+
const key = `${bite.url}__${copyIndex}__${i}`;
|
|
429
|
+
const isClone = copyIndex === 1;
|
|
430
|
+
return (
|
|
431
|
+
// Clone copy: aria-hidden + no focusable descendants (WCAG —
|
|
432
|
+
// same rule as quick-action-marquee). Clones stay pointer-
|
|
433
|
+
// interactive (briefly visible near the wrap seam).
|
|
434
|
+
<VideoBiteCard
|
|
435
|
+
key={key}
|
|
436
|
+
bite={bite}
|
|
437
|
+
index={i}
|
|
438
|
+
cardKey={key}
|
|
439
|
+
isClone={isClone}
|
|
440
|
+
isTouch={isTouch}
|
|
441
|
+
height={cardHeight}
|
|
442
|
+
active={activeKey === key}
|
|
443
|
+
onActivate={activate}
|
|
444
|
+
onDeactivate={deactivate}
|
|
445
|
+
playerMounted={mountedIdx.has(i)}
|
|
446
|
+
rootRef={getCardRef(i)}
|
|
447
|
+
profile={bite.profile ?? profile ?? null}
|
|
448
|
+
sectionHref={href}
|
|
449
|
+
onBiteNavigate={onBiteNavigate}
|
|
450
|
+
/>
|
|
451
|
+
);
|
|
452
|
+
}),
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{showChevrons && overflows && (
|
|
458
|
+
<>
|
|
459
|
+
{/* Solid Figma button-icon look (bg #212121 = ods-card, border soft-grey). */}
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
aria-label="Previous videos"
|
|
463
|
+
onClick={() => scrollByCard(-1)}
|
|
464
|
+
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"
|
|
465
|
+
>
|
|
466
|
+
<Chevron02LeftIcon className="w-6 h-6" />
|
|
467
|
+
</button>
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
aria-label="Next videos"
|
|
471
|
+
onClick={() => scrollByCard(1)}
|
|
472
|
+
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"
|
|
473
|
+
>
|
|
474
|
+
<Chevron02RightIcon className="w-6 h-6" />
|
|
475
|
+
</button>
|
|
476
|
+
</>
|
|
477
|
+
)}
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// =============================================================================
|
|
484
|
+
// Card — THE bite card (exported). One component for every surface: the
|
|
485
|
+
// public strip passes controlled hover/mount state; the admin bites editor
|
|
486
|
+
// renders it standalone (self-managed hover, grid-cell sizing, action slots,
|
|
487
|
+
// inline title editing) so the admin sees EXACTLY the public card.
|
|
488
|
+
// =============================================================================
|
|
489
|
+
|
|
490
|
+
export interface VideoBiteCardProps {
|
|
491
|
+
bite: VideoBiteStripItem;
|
|
492
|
+
index: number;
|
|
493
|
+
profile?: VideoBiteStripProfile | null;
|
|
494
|
+
sectionHref?: string;
|
|
495
|
+
onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
|
|
496
|
+
/** Fixed card height (strip). Omit → width-driven: card fills its grid
|
|
497
|
+
* cell and the aspect-ratio derives the height (editor grids). */
|
|
498
|
+
height?: number;
|
|
499
|
+
/** Controlled hover-activation (strip). Omit → the card manages its own
|
|
500
|
+
* hover/focus state. */
|
|
501
|
+
active?: boolean;
|
|
502
|
+
onActivate?: (key: string) => void;
|
|
503
|
+
onDeactivate?: (key: string) => void;
|
|
504
|
+
cardKey?: string;
|
|
505
|
+
isClone?: boolean;
|
|
506
|
+
isTouch?: boolean;
|
|
507
|
+
/** Controlled player mount (the strip's shared-by-index gate). Omit → the
|
|
508
|
+
* card runs its own two-way near-viewport observer. */
|
|
509
|
+
playerMounted?: boolean;
|
|
510
|
+
/** Root-element ref hook (strip observer registration). May return a
|
|
511
|
+
* cleanup (React 19 ref contract). */
|
|
512
|
+
rootRef?: (el: HTMLDivElement | null) => (() => void) | undefined;
|
|
513
|
+
/** Admin action toolbar rendered BELOW the media on a solid surface —
|
|
514
|
+
* never floated over the video (white icons on a white frame fail the
|
|
515
|
+
* WCAG 3:1 non-text contrast minimum; a solid `bg-ods-card` row always
|
|
516
|
+
* passes). Editor passes publish / download / star / upload / delete. */
|
|
517
|
+
toolbar?: React.ReactNode;
|
|
518
|
+
/** Admin: the overlay title renders as an inline editor. */
|
|
519
|
+
titleEditable?: boolean;
|
|
520
|
+
onTitleChange?: (value: string) => void;
|
|
521
|
+
onTitleCommit?: (value: string) => void;
|
|
522
|
+
className?: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function VideoBiteCard({
|
|
526
|
+
bite,
|
|
527
|
+
index,
|
|
528
|
+
profile = null,
|
|
529
|
+
sectionHref,
|
|
530
|
+
onBiteNavigate,
|
|
531
|
+
height,
|
|
532
|
+
active,
|
|
533
|
+
onActivate,
|
|
534
|
+
onDeactivate,
|
|
535
|
+
cardKey,
|
|
536
|
+
isClone = false,
|
|
537
|
+
isTouch = false,
|
|
538
|
+
playerMounted,
|
|
539
|
+
rootRef,
|
|
540
|
+
toolbar,
|
|
541
|
+
titleEditable = false,
|
|
542
|
+
onTitleChange,
|
|
543
|
+
onTitleCommit,
|
|
544
|
+
className,
|
|
545
|
+
}: VideoBiteCardProps) {
|
|
546
|
+
const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
|
|
547
|
+
const targetHref = bite.href ?? sectionHref;
|
|
548
|
+
const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
|
|
549
|
+
const key = cardKey ?? `${bite.url}__${index}`;
|
|
550
|
+
|
|
551
|
+
// Hover activation: controlled by the strip (activeKey) or self-managed
|
|
552
|
+
// when rendered standalone (admin editor).
|
|
553
|
+
const controlled = active !== undefined;
|
|
554
|
+
const [selfActive, setSelfActive] = useState(false);
|
|
555
|
+
const isActive = controlled ? !!active : selfActive;
|
|
556
|
+
const activate = () => (controlled ? onActivate?.(key) : setSelfActive(true));
|
|
557
|
+
const deactivate = () => (controlled ? onDeactivate?.(key) : setSelfActive(false));
|
|
558
|
+
|
|
559
|
+
// Player mount: controlled (strip's shared-by-index gate) or an internal
|
|
560
|
+
// TWO-WAY near-viewport observer (standalone). Two-way — NOT the fire-once
|
|
561
|
+
// `useNearViewport` — so players unmount again >500px away and live
|
|
562
|
+
// MuxPlayer instances stay bounded.
|
|
563
|
+
const gateControlled = playerMounted !== undefined;
|
|
564
|
+
const rootElRef = useRef<HTMLDivElement | null>(null);
|
|
565
|
+
const [isNear, setIsNear] = useState(false);
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (gateControlled) return;
|
|
568
|
+
const el = rootElRef.current;
|
|
569
|
+
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
570
|
+
const io = new IntersectionObserver(
|
|
571
|
+
entries => setIsNear(entries[0]?.isIntersecting ?? false),
|
|
572
|
+
{ rootMargin: '500px' },
|
|
573
|
+
);
|
|
574
|
+
io.observe(el);
|
|
575
|
+
return () => io.disconnect();
|
|
576
|
+
}, [gateControlled]);
|
|
577
|
+
const showPlayer = gateControlled ? playerMounted : isNear;
|
|
578
|
+
|
|
579
|
+
const navigate = () => {
|
|
580
|
+
if (bite.onNavigate) bite.onNavigate();
|
|
581
|
+
else onBiteNavigate?.(bite, index);
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const handlePointerEnter = () => { if (!isTouch) activate(); };
|
|
585
|
+
const handlePointerLeave = () => { if (!isTouch) deactivate(); };
|
|
586
|
+
const handleClick = () => { if (isTouch && !isActive) activate(); };
|
|
587
|
+
|
|
588
|
+
// Bottom-docked detail (Figma node 4033:90369): title row + profile row +
|
|
589
|
+
// chevron affordance. The WHOLE footer is the navigation target — it links
|
|
590
|
+
// to the entity the bite originated from. In the editor the title row is
|
|
591
|
+
// the inline title editor (edited directly on the card).
|
|
592
|
+
const titleClass = "font-['DM_Sans'] text-sm font-medium leading-5 text-ods-text-primary";
|
|
593
|
+
const overlayContent = (
|
|
594
|
+
<>
|
|
595
|
+
{titleEditable ? (
|
|
596
|
+
// Textarea, not input: the public title wraps to two lines
|
|
597
|
+
// (line-clamp-2) and the editor must render identically. rows=1 +
|
|
598
|
+
// auto-grow keeps short titles single-line; max-height caps at the
|
|
599
|
+
// same two lines the clamp allows.
|
|
600
|
+
<textarea
|
|
601
|
+
value={bite.title || ''}
|
|
602
|
+
placeholder="Title (optional)"
|
|
603
|
+
aria-label="Bite title"
|
|
604
|
+
rows={1}
|
|
605
|
+
onChange={e => {
|
|
606
|
+
e.target.style.height = 'auto';
|
|
607
|
+
e.target.style.height = `${Math.min(e.target.scrollHeight, 40)}px`;
|
|
608
|
+
onTitleChange?.(e.target.value);
|
|
609
|
+
}}
|
|
610
|
+
ref={el => {
|
|
611
|
+
if (el) {
|
|
612
|
+
el.style.height = 'auto';
|
|
613
|
+
el.style.height = `${Math.min(el.scrollHeight, 40)}px`;
|
|
614
|
+
}
|
|
615
|
+
}}
|
|
616
|
+
onBlur={e => onTitleCommit?.(e.target.value)}
|
|
617
|
+
onClick={e => e.stopPropagation()}
|
|
618
|
+
className={cn(
|
|
619
|
+
titleClass,
|
|
620
|
+
'w-full max-h-10 resize-none overflow-hidden bg-transparent outline-none',
|
|
621
|
+
'placeholder:text-ods-text-secondary border-b border-transparent focus:border-ods-border',
|
|
622
|
+
)}
|
|
623
|
+
/>
|
|
624
|
+
) : (
|
|
625
|
+
bite.title && <p className={cn(titleClass, 'line-clamp-2')}>{bite.title}</p>
|
|
626
|
+
)}
|
|
627
|
+
{(profile || hasTarget) && (
|
|
628
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
629
|
+
{profile && (
|
|
630
|
+
<UserDisplay
|
|
631
|
+
name={profile.name}
|
|
632
|
+
avatarUrl={profile.avatarUrl}
|
|
633
|
+
subtitle={profile.subtitle}
|
|
634
|
+
size={22}
|
|
635
|
+
shape="round"
|
|
636
|
+
compact
|
|
637
|
+
className="flex-1"
|
|
638
|
+
/>
|
|
639
|
+
)}
|
|
640
|
+
{hasTarget && (
|
|
641
|
+
<Chevron02RightIcon className="w-5 h-5 shrink-0 ml-auto text-ods-text-primary" />
|
|
642
|
+
)}
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</>
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// Figma node 4033:90369 exactly: pure-black 75% fill (Tailwind palette
|
|
649
|
+
// black — NOT a glassy backdrop blur; Figma has no background blur here),
|
|
650
|
+
// full soft-grey border, p-16/gap-16, large soft drop shadow.
|
|
651
|
+
const overlayClass = cn(
|
|
652
|
+
'absolute inset-x-0 bottom-0 p-3 gap-2 bg-black/75 border border-ods-border shadow-2xl',
|
|
653
|
+
'flex flex-col transition-opacity duration-200',
|
|
654
|
+
isActive ? 'opacity-100' : 'opacity-0 group-hover/card:opacity-100 group-focus-within/card:opacity-100',
|
|
655
|
+
// Non-interactive while invisible so it never swallows clicks on the
|
|
656
|
+
// resting card / player controls.
|
|
657
|
+
isActive ? 'pointer-events-auto' : 'pointer-events-none group-hover/card:pointer-events-auto group-focus-within/card:pointer-events-auto',
|
|
658
|
+
);
|
|
659
|
+
|
|
660
|
+
const media = (
|
|
661
|
+
<div
|
|
662
|
+
className="relative w-full"
|
|
663
|
+
// Strip mode: fixed height + aspect drive the width (capped at 90vw,
|
|
664
|
+
// same as the old single-box layout). Editor mode: width-driven.
|
|
665
|
+
style={{ aspectRatio: cssAspect, ...(height !== undefined ? { height, maxWidth: '90vw' } : {}) }}
|
|
666
|
+
>
|
|
667
|
+
{showPlayer ? (
|
|
668
|
+
// Clones: `inert` (not just the wrapper's aria-hidden) — the player's
|
|
669
|
+
// shadow-DOM center control is otherwise still tab-reachable inside a
|
|
670
|
+
// hidden subtree (axe aria-hidden-focus). Hover preview on clones
|
|
671
|
+
// keeps working: playback is driven imperatively from the CARD's own
|
|
672
|
+
// pointer handlers, never from focus/clicks on the player chrome.
|
|
673
|
+
<div className="absolute inset-0" inert={isClone || undefined}>
|
|
674
|
+
{/* CONTROLLED hover playback keyed to CARD hover (`isActive`): the
|
|
675
|
+
detail overlay is part of the card, so moving the pointer onto
|
|
676
|
+
it keeps playing. Sound at 50% (pre-activation: muted start +
|
|
677
|
+
live unmute on the user's first gesture); chrome = center
|
|
678
|
+
play/pause only (Figma card look). */}
|
|
679
|
+
<Video
|
|
680
|
+
kind="file"
|
|
681
|
+
url={bite.url}
|
|
682
|
+
poster={bite.thumbnail_url}
|
|
683
|
+
playWhenHovered={isActive}
|
|
684
|
+
centerControlsOnly
|
|
685
|
+
layout="fill"
|
|
686
|
+
/>
|
|
687
|
+
</div>
|
|
688
|
+
) : (
|
|
689
|
+
// Aspect-matched placeholder until the card nears the viewport (CLS-free).
|
|
690
|
+
<div className="absolute inset-0 bg-ods-card" />
|
|
691
|
+
)}
|
|
692
|
+
|
|
693
|
+
{hasTarget && !isClone && !titleEditable ? (
|
|
694
|
+
targetHref ? (
|
|
695
|
+
<a href={targetHref} aria-label={`Open ${bite.title || 'source content'}`} className={overlayClass}>
|
|
696
|
+
{overlayContent}
|
|
697
|
+
</a>
|
|
698
|
+
) : (
|
|
699
|
+
<button type="button" onClick={navigate} aria-label={`Open ${bite.title || 'source content'}`} className={cn(overlayClass, 'text-left w-full')}>
|
|
700
|
+
{overlayContent}
|
|
701
|
+
</button>
|
|
702
|
+
)
|
|
703
|
+
) : (
|
|
704
|
+
<div className={overlayClass}>{overlayContent}</div>
|
|
705
|
+
)}
|
|
706
|
+
</div>
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
<div
|
|
711
|
+
ref={node => {
|
|
712
|
+
rootElRef.current = node;
|
|
713
|
+
return rootRef?.(node);
|
|
714
|
+
}}
|
|
715
|
+
aria-hidden={isClone || undefined}
|
|
716
|
+
className={cn(
|
|
717
|
+
'relative rounded-md border border-ods-border bg-ods-card overflow-hidden group/card',
|
|
718
|
+
height !== undefined ? 'shrink-0' : 'w-full',
|
|
719
|
+
className,
|
|
720
|
+
)}
|
|
721
|
+
style={height !== undefined ? { maxWidth: '90vw' } : undefined}
|
|
722
|
+
onPointerEnter={handlePointerEnter}
|
|
723
|
+
onPointerLeave={handlePointerLeave}
|
|
724
|
+
onClick={handleClick}
|
|
725
|
+
onFocus={activate}
|
|
726
|
+
onBlur={deactivate}
|
|
727
|
+
>
|
|
728
|
+
{media}
|
|
729
|
+
{toolbar && (
|
|
730
|
+
// Solid-surface action row UNDER the media (WCAG non-text contrast —
|
|
731
|
+
// see the `toolbar` prop doc). Part of the card, so hovering it keeps
|
|
732
|
+
// hover playback alive.
|
|
733
|
+
<div className="flex items-center justify-between gap-1 border-t border-ods-border bg-ods-card px-2 py-1.5">
|
|
734
|
+
{toolbar}
|
|
735
|
+
</div>
|
|
736
|
+
)}
|
|
737
|
+
</div>
|
|
738
|
+
);
|
|
739
|
+
}
|