@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
|
@@ -208,6 +208,19 @@ interface VideoFileProps extends VideoCommonProps {
|
|
|
208
208
|
srtContent?: string | null;
|
|
209
209
|
/** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
|
|
210
210
|
captionsUrl?: string | null;
|
|
211
|
+
/** Autoplay muted on mount (forwarded as MuxPlayer `autoPlay="muted"`) — hover-preview surfaces. */
|
|
212
|
+
autoPlay?: boolean;
|
|
213
|
+
/** Loop playback — short bite previews. */
|
|
214
|
+
loop?: boolean;
|
|
215
|
+
/** Hide all player chrome (MuxPlayer `--controls: none`) — chromeless preview mode. */
|
|
216
|
+
chromeless?: boolean;
|
|
217
|
+
/** Play while the pointer hovers the player, pause on leave. Tries WITH
|
|
218
|
+
* sound at 50% volume first (bite-strip behavior); falls back to muted when
|
|
219
|
+
* the browser's autoplay policy rejects unmuted hover playback. */
|
|
220
|
+
playOnHover?: boolean;
|
|
221
|
+
/** Hide the bottom control bar, keep only the CENTER play/pause control
|
|
222
|
+
* (bite-strip cards per Figma). */
|
|
223
|
+
centerControlsOnly?: boolean;
|
|
211
224
|
}
|
|
212
225
|
|
|
213
226
|
interface VideoYouTubeProps extends VideoCommonProps {
|
|
@@ -221,6 +234,12 @@ interface VideoAutoProps extends VideoCommonProps {
|
|
|
221
234
|
url: string;
|
|
222
235
|
srtContent?: string | null;
|
|
223
236
|
captionsUrl?: string | null;
|
|
237
|
+
/** See VideoFileProps — no-ops when the URL resolves to the YouTube branch. */
|
|
238
|
+
autoPlay?: boolean;
|
|
239
|
+
loop?: boolean;
|
|
240
|
+
chromeless?: boolean;
|
|
241
|
+
playOnHover?: boolean;
|
|
242
|
+
centerControlsOnly?: boolean;
|
|
224
243
|
}
|
|
225
244
|
|
|
226
245
|
export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
@@ -252,6 +271,11 @@ export function Video(props: VideoProps): React.ReactElement | null {
|
|
|
252
271
|
muted={props.muted}
|
|
253
272
|
srtContent={'srtContent' in props ? props.srtContent : null}
|
|
254
273
|
captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
|
|
274
|
+
autoPlay={'autoPlay' in props ? props.autoPlay : undefined}
|
|
275
|
+
loop={'loop' in props ? props.loop : undefined}
|
|
276
|
+
chromeless={'chromeless' in props ? props.chromeless : undefined}
|
|
277
|
+
playOnHover={'playOnHover' in props ? props.playOnHover : undefined}
|
|
278
|
+
centerControlsOnly={'centerControlsOnly' in props ? props.centerControlsOnly : undefined}
|
|
255
279
|
className={props.className}
|
|
256
280
|
/>
|
|
257
281
|
);
|
|
@@ -308,7 +332,7 @@ function wrapWithLayout(
|
|
|
308
332
|
return <div className="absolute inset-0 w-full h-full">{inner}</div>;
|
|
309
333
|
case 'native':
|
|
310
334
|
default:
|
|
311
|
-
// `native` callers (
|
|
335
|
+
// `native` callers (blog cards etc.) are
|
|
312
336
|
// expected to provide their own aspect-ratio container so the layout
|
|
313
337
|
// primitive doesn't override portrait/square/landscape bites with 16:9.
|
|
314
338
|
return inner;
|
|
@@ -325,6 +349,11 @@ interface FilePlayerProps {
|
|
|
325
349
|
muted?: boolean;
|
|
326
350
|
srtContent?: string | null;
|
|
327
351
|
captionsUrl?: string | null;
|
|
352
|
+
autoPlay?: boolean;
|
|
353
|
+
loop?: boolean;
|
|
354
|
+
chromeless?: boolean;
|
|
355
|
+
playOnHover?: boolean;
|
|
356
|
+
centerControlsOnly?: boolean;
|
|
328
357
|
className?: string;
|
|
329
358
|
}
|
|
330
359
|
|
|
@@ -334,8 +363,72 @@ function FilePlayer({
|
|
|
334
363
|
muted,
|
|
335
364
|
srtContent,
|
|
336
365
|
captionsUrl,
|
|
366
|
+
autoPlay,
|
|
367
|
+
loop,
|
|
368
|
+
chromeless,
|
|
369
|
+
playOnHover,
|
|
370
|
+
centerControlsOnly,
|
|
337
371
|
className,
|
|
338
372
|
}: FilePlayerProps): React.ReactElement {
|
|
373
|
+
// centerControlsOnly: the center play button shows at REST only — while
|
|
374
|
+
// playing, ALL chrome hides (no pause sign over the hover-preview).
|
|
375
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
376
|
+
// playOnHover drives the underlying mux-player element imperatively — the
|
|
377
|
+
// element exposes native play()/pause()/muted/volume; the chrome stays as
|
|
378
|
+
// configured. Sound-first: volume 0.5 unmuted, muted fallback when the
|
|
379
|
+
// browser's autoplay policy rejects unmuted hover playback (hover is not a
|
|
380
|
+
// user gesture in Chrome's activation model).
|
|
381
|
+
const hoverPlayerRef = useRef<{
|
|
382
|
+
play?: () => Promise<void> | void;
|
|
383
|
+
pause?: () => void;
|
|
384
|
+
muted?: boolean;
|
|
385
|
+
volume?: number;
|
|
386
|
+
} | null>(null);
|
|
387
|
+
// Tracks whether the pointer is STILL over the player. A fast hover-out
|
|
388
|
+
// pauses the in-flight play(), which rejects it with AbortError — that must
|
|
389
|
+
// NOT trigger the muted retry (it would restart playback after the pointer
|
|
390
|
+
// left, with no visible control to stop it). Only a genuine autoplay-policy
|
|
391
|
+
// rejection (NotAllowedError) while still hovered retries muted.
|
|
392
|
+
const hoverActiveRef = useRef(false);
|
|
393
|
+
// Per-enter generation token: a LATE NotAllowedError from a previous enter
|
|
394
|
+
// must not fire the muted fallback into a newer (intended-sound) session.
|
|
395
|
+
const hoverGenerationRef = useRef(0);
|
|
396
|
+
const handleHoverEnter = playOnHover
|
|
397
|
+
? () => {
|
|
398
|
+
hoverActiveRef.current = true;
|
|
399
|
+
const generation = ++hoverGenerationRef.current;
|
|
400
|
+
const el = hoverPlayerRef.current;
|
|
401
|
+
if (!el) return;
|
|
402
|
+
try {
|
|
403
|
+
el.volume = 0.5;
|
|
404
|
+
el.muted = false;
|
|
405
|
+
const attempt = el.play?.();
|
|
406
|
+
if (attempt && typeof (attempt as Promise<void>).catch === 'function') {
|
|
407
|
+
(attempt as Promise<void>).catch((err: unknown) => {
|
|
408
|
+
const name = (err as { name?: string } | null)?.name;
|
|
409
|
+
if (
|
|
410
|
+
name === 'NotAllowedError' &&
|
|
411
|
+
hoverActiveRef.current &&
|
|
412
|
+
generation === hoverGenerationRef.current
|
|
413
|
+
) {
|
|
414
|
+
try {
|
|
415
|
+
el.muted = true;
|
|
416
|
+
// Swallow the retry's own rejection too (a hover-out mid-retry
|
|
417
|
+
// aborts it — that must not surface as an unhandled rejection).
|
|
418
|
+
(el.play?.() as Promise<void> | undefined)?.catch?.(() => {});
|
|
419
|
+
} catch { /* give up silently */ }
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
} catch { /* ignore */ }
|
|
424
|
+
}
|
|
425
|
+
: undefined;
|
|
426
|
+
const handleHoverLeave = playOnHover
|
|
427
|
+
? () => {
|
|
428
|
+
hoverActiveRef.current = false;
|
|
429
|
+
try { hoverPlayerRef.current?.pause?.(); } catch { /* already torn down */ }
|
|
430
|
+
}
|
|
431
|
+
: undefined;
|
|
339
432
|
// Raw SRT text is unusable without a custom overlay — and we just deleted
|
|
340
433
|
// the 900-LOC custom-controls layer that owned that overlay. Consumers
|
|
341
434
|
// pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
|
|
@@ -349,8 +442,11 @@ function FilePlayer({
|
|
|
349
442
|
);
|
|
350
443
|
}
|
|
351
444
|
|
|
352
|
-
|
|
445
|
+
const player = (
|
|
353
446
|
<MuxPlayer
|
|
447
|
+
ref={hoverPlayerRef as React.Ref<never>}
|
|
448
|
+
onPlay={centerControlsOnly ? () => setIsPlaying(true) : undefined}
|
|
449
|
+
onPause={centerControlsOnly ? () => setIsPlaying(false) : undefined}
|
|
354
450
|
src={url}
|
|
355
451
|
poster={poster || undefined}
|
|
356
452
|
streamType="on-demand"
|
|
@@ -365,6 +461,8 @@ function FilePlayer({
|
|
|
365
461
|
// is ever undefined on a `data-app-type` we haven't themed yet.
|
|
366
462
|
// NEVER let Mux pink leak onto a non-Flamingo platform.
|
|
367
463
|
accentColor="var(--ods-accent, var(--color-accent-primary))"
|
|
464
|
+
autoPlay={autoPlay ? 'muted' : undefined}
|
|
465
|
+
loop={loop}
|
|
368
466
|
className={className}
|
|
369
467
|
// Fill the wrapping aspect-ratio container instead of MuxPlayer's
|
|
370
468
|
// intrinsic size. Without this, MuxPlayer renders at its default
|
|
@@ -373,7 +471,25 @@ function FilePlayer({
|
|
|
373
471
|
// flickers and grows" CLS we're killing. With `aspect-video` on
|
|
374
472
|
// the centered wrapper and `width/height: 100%` here, the box is
|
|
375
473
|
// 16:9 from first paint and stays put.
|
|
376
|
-
|
|
474
|
+
// `--controls: none` is media-chrome's kill switch for ALL player
|
|
475
|
+
// chrome — the chromeless preview mode. `--bottom-controls: none`
|
|
476
|
+
// hides only the bottom bar (center play/pause stays) — the
|
|
477
|
+
// bite-strip card look per Figma. Merged (never replacing) into the
|
|
478
|
+
// sizing style; custom properties need the CSSProperties cast.
|
|
479
|
+
style={{
|
|
480
|
+
width: '100%',
|
|
481
|
+
height: '100%',
|
|
482
|
+
...(chromeless ? ({ '--controls': 'none' } as React.CSSProperties) : {}),
|
|
483
|
+
...(centerControlsOnly && !chromeless
|
|
484
|
+
? ({
|
|
485
|
+
'--bottom-controls': 'none',
|
|
486
|
+
'--top-controls': 'none',
|
|
487
|
+
// While playing, hide the center control too — no pause sign
|
|
488
|
+
// over the hover-preview; it reappears when playback pauses.
|
|
489
|
+
...(isPlaying ? { '--center-controls': 'none' } : {}),
|
|
490
|
+
} as React.CSSProperties)
|
|
491
|
+
: {}),
|
|
492
|
+
}}
|
|
377
493
|
>
|
|
378
494
|
{captionsUrl ? (
|
|
379
495
|
<track
|
|
@@ -386,6 +502,21 @@ function FilePlayer({
|
|
|
386
502
|
) : null}
|
|
387
503
|
</MuxPlayer>
|
|
388
504
|
);
|
|
505
|
+
|
|
506
|
+
// MuxPlayerProps has no pointer-event props — the hover-play handlers live
|
|
507
|
+
// on a full-size wrapper instead (only rendered in playOnHover mode).
|
|
508
|
+
if (playOnHover) {
|
|
509
|
+
return (
|
|
510
|
+
<div
|
|
511
|
+
className="w-full h-full"
|
|
512
|
+
onPointerEnter={handleHoverEnter}
|
|
513
|
+
onPointerLeave={handleHoverLeave}
|
|
514
|
+
>
|
|
515
|
+
{player}
|
|
516
|
+
</div>
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
return player;
|
|
389
520
|
}
|
|
390
521
|
|
|
391
522
|
// -----------------------------------------------------------------------------
|
|
@@ -26,7 +26,8 @@ import { useRouter } from '../../embed-shims'
|
|
|
26
26
|
import { PageShell } from '../layout/article-detail-layout'
|
|
27
27
|
import { DetailPageSkeleton } from '../shared/detail-page-skeleton'
|
|
28
28
|
import { EntityVideoSection } from '../features/entity-video-section'
|
|
29
|
-
import {
|
|
29
|
+
import { VideoBitesStrip } from '../features/video-bites-strip'
|
|
30
|
+
import { toStripProfile } from '../features/video-bites-shared'
|
|
30
31
|
import { useVideoWarmup } from '../features/use-video-warmup'
|
|
31
32
|
import { getCaptionsUrl } from '../features/captions-url'
|
|
32
33
|
import { RichMarkdownRenderer } from '../ui/rich-markdown-renderer'
|
|
@@ -208,14 +209,14 @@ export function OnboardingGuideDetailView({
|
|
|
208
209
|
</div>
|
|
209
210
|
)}
|
|
210
211
|
|
|
211
|
-
{/* Video Bites */}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
212
|
+
{/* Video Bites — unified strip; renders nothing when no published bites. */}
|
|
213
|
+
<VideoBitesStrip
|
|
214
|
+
bites={(guide.video_bites as VideoTeaser[] | null) ?? []}
|
|
215
|
+
filterPublished={true}
|
|
216
|
+
showTitle={false}
|
|
217
|
+
profile={toStripProfile(guide.author)}
|
|
218
|
+
href={`/onboarding-guides/${guide.slug}`}
|
|
219
|
+
/>
|
|
219
220
|
|
|
220
221
|
{/* End-of-article author byline (avatar + linked name + bio) — the
|
|
221
222
|
author DESCRIPTION block every article-shaped detail page renders.
|
|
@@ -25,6 +25,11 @@ import { DetailPageSkeleton } from '../detail-page-skeleton';
|
|
|
25
25
|
import type { ChangelogEntry } from '../../../types/product-release';
|
|
26
26
|
import type { TagAssoc } from '../../../types/blog';
|
|
27
27
|
import type { VideoTeaser } from '../../../types/video-processing';
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_VIDEO_BITES_TITLE,
|
|
30
|
+
toStripProfile,
|
|
31
|
+
type VideoBiteStripProfile,
|
|
32
|
+
} from '../../features/video-bites-shared';
|
|
28
33
|
|
|
29
34
|
// Types for injectable components
|
|
30
35
|
export interface MarkdownRendererProps {
|
|
@@ -59,12 +64,6 @@ export interface DeliverySectionProps {
|
|
|
59
64
|
isLoading: boolean;
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
export interface VideoSectionProps {
|
|
63
|
-
bites: VideoTeaser[];
|
|
64
|
-
title?: string;
|
|
65
|
-
filterPublished?: boolean;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
67
|
// Type for the useRelease hook result
|
|
69
68
|
export interface UseReleaseResult {
|
|
70
69
|
data: unknown;
|
|
@@ -83,6 +82,10 @@ export interface VideoDisplaySectionProps {
|
|
|
83
82
|
videoBites?: VideoTeaser[];
|
|
84
83
|
bitesTitle?: string;
|
|
85
84
|
filterPublishedBites?: boolean;
|
|
85
|
+
/** Profile shown in the bites strip's hover overlay (kit sources it from release.author). */
|
|
86
|
+
bitesProfile?: VideoBiteStripProfile | null;
|
|
87
|
+
/** Overlay-footer navigation target (the release the bites originated from). */
|
|
88
|
+
bitesHref?: string;
|
|
86
89
|
srtContent?: string | null;
|
|
87
90
|
captionsUrl?: string | null;
|
|
88
91
|
}
|
|
@@ -96,7 +99,6 @@ export interface ReleaseDetailPageProps {
|
|
|
96
99
|
MarkdownRenderer?: ComponentType<MarkdownRendererProps>;
|
|
97
100
|
RoadmapSection?: ComponentType<RoadmapSectionProps>;
|
|
98
101
|
DeliverySection?: ComponentType<DeliverySectionProps>;
|
|
99
|
-
VideoSection?: ComponentType<VideoSectionProps>;
|
|
100
102
|
/** Injectable video display section with tabs for full/highlight video + summary + bites */
|
|
101
103
|
VideoDisplaySection?: ComponentType<VideoDisplaySectionProps>;
|
|
102
104
|
// API endpoints for fetching linked tasks
|
|
@@ -134,7 +136,6 @@ export function ReleaseDetailPage({
|
|
|
134
136
|
MarkdownRenderer = DefaultMarkdownRenderer,
|
|
135
137
|
RoadmapSection,
|
|
136
138
|
DeliverySection,
|
|
137
|
-
VideoSection,
|
|
138
139
|
VideoDisplaySection,
|
|
139
140
|
roadmapApiEndpoint = '/api/roadmap',
|
|
140
141
|
deliveryApiEndpoint = '/api/delivery',
|
|
@@ -335,8 +336,10 @@ export function ReleaseDetailPage({
|
|
|
335
336
|
highlightVideoThumbnail={highlightVideoThumbnail}
|
|
336
337
|
title={releaseTitle}
|
|
337
338
|
videoBites={videoBites}
|
|
338
|
-
bitesTitle=
|
|
339
|
+
bitesTitle={DEFAULT_VIDEO_BITES_TITLE}
|
|
339
340
|
filterPublishedBites={true}
|
|
341
|
+
bitesProfile={toStripProfile(author)}
|
|
342
|
+
bitesHref={typeof release.slug === 'string' ? `/releases/${release.slug}` : undefined}
|
|
340
343
|
srtContent={release?.srt_content as string | null | undefined}
|
|
341
344
|
captionsUrl={release?.captionsUrl as string | undefined}
|
|
342
345
|
/>
|
|
@@ -434,15 +437,6 @@ export function ReleaseDetailPage({
|
|
|
434
437
|
MarkdownRenderer={MarkdownRenderer}
|
|
435
438
|
/>
|
|
436
439
|
|
|
437
|
-
{/* Video Bites Section - Only when VideoDisplaySection is not handling it */}
|
|
438
|
-
{!VideoDisplaySection && VideoSection && videoBites && videoBites.length > 0 && (
|
|
439
|
-
<VideoSection
|
|
440
|
-
bites={videoBites}
|
|
441
|
-
title="Video Clips"
|
|
442
|
-
filterPublished={true}
|
|
443
|
-
/>
|
|
444
|
-
)}
|
|
445
|
-
|
|
446
440
|
{/* Related Roadmap Items */}
|
|
447
441
|
{RoadmapSection && (roadmapLoading || roadmapTasks.length > 0) && (
|
|
448
442
|
<div className="space-y-4 w-full">
|
|
@@ -13,6 +13,8 @@ interface SquareAvatarProps {
|
|
|
13
13
|
size?: number;
|
|
14
14
|
/** If true, avatar takes full width with square aspect ratio */
|
|
15
15
|
fullWidth?: boolean;
|
|
16
|
+
/** Corner treatment. `'square'` = rounded-lg (default, historical look); `'round'` = fully circular. */
|
|
17
|
+
shape?: 'square' | 'round';
|
|
16
18
|
className?: string;
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -20,7 +22,7 @@ interface SquareAvatarProps {
|
|
|
20
22
|
* Square avatar with rounded edges used across cards / dashboards.
|
|
21
23
|
* Automatically shows image (via proxied URL) or initials fallback.
|
|
22
24
|
*/
|
|
23
|
-
export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, className }: SquareAvatarProps) {
|
|
25
|
+
export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = false, shape = 'square', className }: SquareAvatarProps) {
|
|
24
26
|
const initials = React.useMemo(() => {
|
|
25
27
|
if (!fallbackName) return '';
|
|
26
28
|
return fallbackName
|
|
@@ -36,7 +38,8 @@ export function SquareAvatar({ src, fallbackName = '', size = 56, fullWidth = fa
|
|
|
36
38
|
return (
|
|
37
39
|
<div
|
|
38
40
|
className={cn(
|
|
39
|
-
'
|
|
41
|
+
'border border-ods-border flex items-center justify-center overflow-hidden bg-ods-bg-secondary',
|
|
42
|
+
shape === 'round' ? 'rounded-full' : 'rounded-lg',
|
|
40
43
|
fullWidth ? 'w-full aspect-square' : 'flex-shrink-0',
|
|
41
44
|
className,
|
|
42
45
|
)}
|
|
@@ -11,6 +11,10 @@ interface UserDisplayProps {
|
|
|
11
11
|
subtitle?: string | null;
|
|
12
12
|
/** Avatar size in px (defaults 32) */
|
|
13
13
|
size?: number;
|
|
14
|
+
/** Avatar corner treatment, forwarded to SquareAvatar. Default 'square'. */
|
|
15
|
+
shape?: 'square' | 'round';
|
|
16
|
+
/** Compact typography (14px/20px rows — video-bite overlay density). */
|
|
17
|
+
compact?: boolean;
|
|
14
18
|
className?: string;
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -18,16 +22,22 @@ interface UserDisplayProps {
|
|
|
18
22
|
* Reusable horizontal avatar + name (+ optional subtitle) row that follows
|
|
19
23
|
* the visual pattern used in CommentCard headers.
|
|
20
24
|
*/
|
|
21
|
-
export function UserDisplay({ name, avatarUrl, subtitle, size = 32, className }: UserDisplayProps) {
|
|
25
|
+
export function UserDisplay({ name, avatarUrl, subtitle, size = 32, shape = 'square', compact = false, className }: UserDisplayProps) {
|
|
22
26
|
return (
|
|
23
27
|
<div className={cn('flex items-center gap-2 min-w-0', className)}>
|
|
24
|
-
<SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} />
|
|
28
|
+
<SquareAvatar src={avatarUrl ?? undefined} fallbackName={name} size={size} shape={shape} />
|
|
25
29
|
<div className="min-w-0 flex-1">
|
|
26
|
-
<p className=
|
|
30
|
+
<p className={cn(
|
|
31
|
+
"font-['DM_Sans'] text-ods-text-primary truncate",
|
|
32
|
+
compact ? 'text-sm leading-5 font-medium' : 'text-lg leading-[22px]',
|
|
33
|
+
)}>
|
|
27
34
|
{name}
|
|
28
35
|
</p>
|
|
29
36
|
{subtitle && (
|
|
30
|
-
<span className=
|
|
37
|
+
<span className={cn(
|
|
38
|
+
"font-['DM_Sans'] text-ods-text-secondary truncate block",
|
|
39
|
+
compact ? 'text-sm leading-5 font-medium' : 'text-md leading-[16px]',
|
|
40
|
+
)}>
|
|
31
41
|
{subtitle}
|
|
32
42
|
</span>
|
|
33
43
|
)}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
|
2
|
+
import {
|
|
3
|
+
VideoBitesStrip,
|
|
4
|
+
type VideoBiteStripItem,
|
|
5
|
+
} from '../components/features/video-bites-strip'
|
|
6
|
+
|
|
7
|
+
// Public-domain sample clips (Google CDN test media) — small MP4s so the
|
|
8
|
+
// hover-mount preview is exercised realistically in Storybook.
|
|
9
|
+
const SAMPLE_MP4 =
|
|
10
|
+
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4'
|
|
11
|
+
const SAMPLE_MP4_2 =
|
|
12
|
+
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4'
|
|
13
|
+
|
|
14
|
+
const bite = (over: Partial<VideoBiteStripItem>): VideoBiteStripItem => ({
|
|
15
|
+
url: SAMPLE_MP4,
|
|
16
|
+
title: 'How an MSSP Director Tests Agentic AI for Regulated Law Firms',
|
|
17
|
+
published: true,
|
|
18
|
+
aspect_ratio: '9:16',
|
|
19
|
+
created_at: '2026-07-01T00:00:00Z',
|
|
20
|
+
thumbnail_url: 'https://picsum.photos/seed/bite/270/480',
|
|
21
|
+
...over,
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const PROFILE = {
|
|
25
|
+
name: 'Michael Crisafio',
|
|
26
|
+
avatarUrl: 'https://picsum.photos/seed/avatar/68/68',
|
|
27
|
+
subtitle: 'Tier 1 Consulting',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MANY_MIXED: VideoBiteStripItem[] = [
|
|
31
|
+
bite({ title: 'Portrait clip A', href: '#a' }),
|
|
32
|
+
bite({ url: SAMPLE_MP4_2, title: 'Landscape clip', aspect_ratio: '16:9', thumbnail_url: 'https://picsum.photos/seed/land/480/270', href: '#b' }),
|
|
33
|
+
bite({ title: 'Square clip', aspect_ratio: '1:1', thumbnail_url: 'https://picsum.photos/seed/sq/360/360' }),
|
|
34
|
+
bite({ title: 'No-thumbnail clip (PlayIcon fallback)', thumbnail_url: undefined }),
|
|
35
|
+
bite({ title: 'Portrait clip B', thumbnail_url: 'https://picsum.photos/seed/b2/270/480' }),
|
|
36
|
+
bite({ title: 'Portrait clip C', thumbnail_url: 'https://picsum.photos/seed/b3/270/480' }),
|
|
37
|
+
bite({ title: 'Portrait clip D', thumbnail_url: 'https://picsum.photos/seed/b4/270/480' }),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const meta: Meta<typeof VideoBitesStrip> = {
|
|
41
|
+
title: 'Features/VideoBitesStrip',
|
|
42
|
+
component: VideoBitesStrip,
|
|
43
|
+
parameters: { layout: 'fullscreen' },
|
|
44
|
+
decorators: [
|
|
45
|
+
Story => (
|
|
46
|
+
<div className="bg-ods-bg p-8 min-h-[560px]">
|
|
47
|
+
<Story />
|
|
48
|
+
</div>
|
|
49
|
+
),
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
export default meta
|
|
53
|
+
|
|
54
|
+
type Story = StoryObj<typeof VideoBitesStrip>
|
|
55
|
+
|
|
56
|
+
/** Mixed ratios at uniform height — the marquee engages once content overflows. */
|
|
57
|
+
export const MixedRatiosMarquee: Story = {
|
|
58
|
+
args: { bites: MANY_MIXED, profile: PROFILE },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Two bites — no overflow, so no clones, no marquee, no chevrons. */
|
|
62
|
+
export const TwoBitesNoMarquee: Story = {
|
|
63
|
+
args: { bites: MANY_MIXED.slice(0, 2), profile: PROFILE },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Missing thumbnails fall back to bg-ods-card + PlayIcon; preview still mounts on hover. */
|
|
67
|
+
export const NoThumbnails: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
bites: MANY_MIXED.map(b => ({ ...b, thumbnail_url: undefined })),
|
|
70
|
+
profile: PROFILE,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** autoScroll off — plain scrollable row with chevrons (reduced-motion behaves the same). */
|
|
75
|
+
export const AutoScrollDisabled: Story = {
|
|
76
|
+
args: { bites: MANY_MIXED, autoScroll: false, profile: PROFILE },
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Unpublished bites are filtered by default — this renders nothing (returns null). */
|
|
80
|
+
export const AllUnpublishedRendersNull: Story = {
|
|
81
|
+
args: { bites: MANY_MIXED.map(b => ({ ...b, published: false })) },
|
|
82
|
+
}
|
package/src/types/case-study.ts
CHANGED
|
@@ -34,6 +34,19 @@ export interface CaseStudy {
|
|
|
34
34
|
video_bites: VideoTeaser[] // Array of video clips from interviews or manually added
|
|
35
35
|
customer_interview_id: number | null // Linked customer interview for bidirectional relationship
|
|
36
36
|
|
|
37
|
+
// Video processing pipeline (FULL parity with customer_interviews since
|
|
38
|
+
// 2026-07: transcribe & summarize, captions, clip config, highlight).
|
|
39
|
+
// NOTE: transcript_words_data is server-only and intentionally NOT typed here.
|
|
40
|
+
transcript?: string | null
|
|
41
|
+
srt_content?: string | null
|
|
42
|
+
video_summary?: string | null
|
|
43
|
+
custom_instructions?: string | null
|
|
44
|
+
config?: Record<string, unknown> | null // clip/highlight processing config
|
|
45
|
+
highlight_video_url?: string | null
|
|
46
|
+
highlight_video_thumbnail?: string | null
|
|
47
|
+
highlight_video_source?: 'manual' | 'ai_generated' | null
|
|
48
|
+
highlight_video_duration_ms?: number | null
|
|
49
|
+
|
|
37
50
|
// SEO
|
|
38
51
|
seo_title: string | null
|
|
39
52
|
seo_description: string | null
|
|
@@ -85,6 +98,16 @@ export interface CreateCaseStudyData {
|
|
|
85
98
|
video_source?: 'manual' | 'ai_generated'
|
|
86
99
|
video_bites?: VideoTeaser[]
|
|
87
100
|
customer_interview_id?: number | null
|
|
101
|
+
// Video processing pipeline (parity with customer_interviews)
|
|
102
|
+
transcript?: string | null
|
|
103
|
+
srt_content?: string | null
|
|
104
|
+
video_summary?: string | null
|
|
105
|
+
custom_instructions?: string | null
|
|
106
|
+
config?: Record<string, unknown> | null
|
|
107
|
+
highlight_video_url?: string | null
|
|
108
|
+
highlight_video_thumbnail?: string | null
|
|
109
|
+
highlight_video_source?: string | null
|
|
110
|
+
highlight_video_duration_ms?: number | null
|
|
88
111
|
// SEO
|
|
89
112
|
seo_title?: string
|
|
90
113
|
seo_description?: string
|
|
@@ -4,14 +4,25 @@
|
|
|
4
4
|
/**
|
|
5
5
|
* VideoTeaser represents a short video clip extracted from a longer video.
|
|
6
6
|
* Used for teasers/bites that can be shown on preview pages.
|
|
7
|
+
*
|
|
8
|
+
* CANONICAL BITE SHAPE: this is the base persistence type — the TRUE SUPERSET
|
|
9
|
+
* of every field persisted in the `video_bites` / `teasers` jsonb columns.
|
|
10
|
+
* `VideoTeaserWithRatio` (video-ratio-tabs.tsx) adds only `aspect_ratio` and is
|
|
11
|
+
* the one type display/aggregation code extends. The AI-extraction `VideoClip`
|
|
12
|
+
* below stays a separate shape by design.
|
|
7
13
|
*/
|
|
8
14
|
export interface VideoTeaser {
|
|
9
15
|
url: string
|
|
10
16
|
title?: string
|
|
11
17
|
thumbnail_url?: string // Optional thumbnail image URL for video preview. If not provided, video player will show first frame automatically.
|
|
12
18
|
published?: boolean // Controls visibility on public preview page (default: false, admin must select)
|
|
19
|
+
featured?: boolean // Opts the bite into cross-entity featured pulls (homepage/configured surfaces). Independent of `published`.
|
|
13
20
|
source?: 'manual' | 'ai_generated' // Track origin of teaser
|
|
14
21
|
created_at?: string // ISO timestamp for sorting (newer items first)
|
|
22
|
+
start_time_ms?: number // Vizard extraction: clip start offset in the source video
|
|
23
|
+
end_time_ms?: number // Vizard extraction: clip end offset in the source video
|
|
24
|
+
confidence?: number // Vizard extraction: 0-100 virality confidence
|
|
25
|
+
viral_reason?: string // Vizard extraction: why this clip was picked
|
|
15
26
|
// Duration auto-detected from video file
|
|
16
27
|
}
|
|
17
28
|
|