@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.
Files changed (132) hide show
  1. package/dist/{chunk-SOAWTQXO.cjs → chunk-2JCSORCJ.cjs} +31 -28
  2. package/dist/chunk-2JCSORCJ.cjs.map +1 -0
  3. package/dist/{chunk-OLCNA32C.cjs → chunk-3JQUNKWY.cjs} +15 -15
  4. package/dist/{chunk-OLCNA32C.cjs.map → chunk-3JQUNKWY.cjs.map} +1 -1
  5. package/dist/{chunk-OX75ZFN6.js → chunk-44Q3IXZM.js} +2 -2
  6. package/dist/{chunk-MVY4ZKQR.js → chunk-4OLIDOLO.js} +2 -2
  7. package/dist/{chunk-ADEP6E3Z.cjs → chunk-7MKU2EB4.cjs} +26 -26
  8. package/dist/{chunk-ADEP6E3Z.cjs.map → chunk-7MKU2EB4.cjs.map} +1 -1
  9. package/dist/{chunk-EULRZYQH.js → chunk-BJPQJNSC.js} +9 -14
  10. package/dist/chunk-BJPQJNSC.js.map +1 -0
  11. package/dist/{chunk-XGLZK25V.js → chunk-CQXHHVMR.js} +2 -2
  12. package/dist/{chunk-POJRURML.cjs → chunk-EHTE3CTT.cjs} +37 -37
  13. package/dist/{chunk-POJRURML.cjs.map → chunk-EHTE3CTT.cjs.map} +1 -1
  14. package/dist/{chunk-DKN425XR.cjs → chunk-GNB5ERZP.cjs} +1923 -1227
  15. package/dist/chunk-GNB5ERZP.cjs.map +1 -0
  16. package/dist/{chunk-ANPV7GZT.js → chunk-IDWKP5H5.js} +5974 -5278
  17. package/dist/chunk-IDWKP5H5.js.map +1 -0
  18. package/dist/{chunk-3KA5L7LC.js → chunk-JSFHTIKA.js} +2 -2
  19. package/dist/{chunk-GPTS2NXW.js → chunk-KA4WXCN3.js} +12 -9
  20. package/dist/chunk-KA4WXCN3.js.map +1 -0
  21. package/dist/{chunk-DU2X2MH2.js → chunk-LFLFQ422.js} +2 -2
  22. package/dist/{chunk-N2LW5NHS.js → chunk-M6ODI2F5.js} +2 -2
  23. package/dist/{chunk-DO7LOWFE.cjs → chunk-MIEODAAV.cjs} +9 -9
  24. package/dist/{chunk-DO7LOWFE.cjs.map → chunk-MIEODAAV.cjs.map} +1 -1
  25. package/dist/{chunk-44ENRZZJ.cjs → chunk-MPOP47ME.cjs} +12 -12
  26. package/dist/{chunk-44ENRZZJ.cjs.map → chunk-MPOP47ME.cjs.map} +1 -1
  27. package/dist/{chunk-EHVRW3WB.js → chunk-NULFIYK2.js} +2 -2
  28. package/dist/{chunk-CIH2LZI7.cjs → chunk-OBKCQ7LA.cjs} +62 -62
  29. package/dist/{chunk-CIH2LZI7.cjs.map → chunk-OBKCQ7LA.cjs.map} +1 -1
  30. package/dist/{chunk-3L4B7MBD.cjs → chunk-Q4YGG3CL.cjs} +31 -31
  31. package/dist/{chunk-3L4B7MBD.cjs.map → chunk-Q4YGG3CL.cjs.map} +1 -1
  32. package/dist/{chunk-SECF2KWQ.cjs → chunk-TQZLC3BQ.cjs} +5 -5
  33. package/dist/{chunk-SECF2KWQ.cjs.map → chunk-TQZLC3BQ.cjs.map} +1 -1
  34. package/dist/{chunk-RUHE3RJK.cjs → chunk-VXJBE5NX.cjs} +62 -67
  35. package/dist/chunk-VXJBE5NX.cjs.map +1 -0
  36. package/dist/{chunk-OGNKGDHT.js → chunk-WIMXB5EU.js} +4 -4
  37. package/dist/{chunk-3BPVTHOW.js → chunk-XGOX5UQ2.js} +4 -4
  38. package/dist/{chunk-SGULN3RX.cjs → chunk-ZFZAHLI4.cjs} +7 -7
  39. package/dist/{chunk-SGULN3RX.cjs.map → chunk-ZFZAHLI4.cjs.map} +1 -1
  40. package/dist/components/case-studies/index.cjs +8 -8
  41. package/dist/components/case-studies/index.js +2 -2
  42. package/dist/components/chat/index.cjs +2 -2
  43. package/dist/components/chat/index.js +1 -1
  44. package/dist/components/contact/index.cjs +3 -3
  45. package/dist/components/contact/index.js +2 -2
  46. package/dist/components/docs/index.cjs +5 -5
  47. package/dist/components/docs/index.js +4 -4
  48. package/dist/components/embeds/index.cjs +3 -3
  49. package/dist/components/embeds/index.js +2 -2
  50. package/dist/components/faq/index.cjs +3 -3
  51. package/dist/components/faq/index.js +2 -2
  52. package/dist/components/features/entity-video-section.d.ts +15 -3
  53. package/dist/components/features/entity-video-section.d.ts.map +1 -1
  54. package/dist/components/features/index.cjs +10 -2
  55. package/dist/components/features/index.cjs.map +1 -1
  56. package/dist/components/features/index.d.ts +2 -0
  57. package/dist/components/features/index.d.ts.map +1 -1
  58. package/dist/components/features/index.js +11 -3
  59. package/dist/components/features/video-bites-display.d.ts +12 -26
  60. package/dist/components/features/video-bites-display.d.ts.map +1 -1
  61. package/dist/components/features/video-bites-shared.cjs +24 -0
  62. package/dist/components/features/video-bites-shared.cjs.map +1 -0
  63. package/dist/components/features/video-bites-shared.d.ts +47 -0
  64. package/dist/components/features/video-bites-shared.d.ts.map +1 -0
  65. package/dist/components/features/video-bites-shared.js +20 -0
  66. package/dist/components/features/video-bites-shared.js.map +1 -0
  67. package/dist/components/features/video-bites-strip.d.ts +114 -0
  68. package/dist/components/features/video-bites-strip.d.ts.map +1 -0
  69. package/dist/components/features/video-ratio-tabs.d.ts +6 -9
  70. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -1
  71. package/dist/components/features/video.d.ts +25 -0
  72. package/dist/components/features/video.d.ts.map +1 -1
  73. package/dist/components/help-center-pages/index.cjs +20 -20
  74. package/dist/components/help-center-pages/index.js +11 -11
  75. package/dist/components/index.cjs +130 -165
  76. package/dist/components/index.cjs.map +1 -1
  77. package/dist/components/index.js +333 -368
  78. package/dist/components/index.js.map +1 -1
  79. package/dist/components/navigation/index.cjs +2 -2
  80. package/dist/components/navigation/index.js +1 -1
  81. package/dist/components/onboarding-guides/index.cjs +5 -5
  82. package/dist/components/onboarding-guides/index.js +4 -4
  83. package/dist/components/onboarding-guides/onboarding-guide-detail-view.d.ts.map +1 -1
  84. package/dist/components/related-content/index.cjs +3 -3
  85. package/dist/components/related-content/index.js +2 -2
  86. package/dist/components/shared/product-release/release-detail-page.d.ts +6 -7
  87. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  88. package/dist/components/square-avatar.d.ts +3 -1
  89. package/dist/components/square-avatar.d.ts.map +1 -1
  90. package/dist/components/tickets/index.cjs +5 -5
  91. package/dist/components/tickets/index.js +4 -4
  92. package/dist/components/ui/index.cjs +2 -2
  93. package/dist/components/ui/index.js +1 -1
  94. package/dist/components/user-display.d.ts +5 -1
  95. package/dist/components/user-display.d.ts.map +1 -1
  96. package/dist/index.cjs +10 -2
  97. package/dist/index.cjs.map +1 -1
  98. package/dist/index.js +11 -3
  99. package/dist/types/case-study.d.ts +18 -0
  100. package/dist/types/case-study.d.ts.map +1 -1
  101. package/dist/types/video-processing.d.ts +11 -0
  102. package/dist/types/video-processing.d.ts.map +1 -1
  103. package/package.json +7 -1
  104. package/src/components/features/entity-video-section.tsx +26 -5
  105. package/src/components/features/index.ts +6 -3
  106. package/src/components/features/video-bites-display.tsx +17 -189
  107. package/src/components/features/video-bites-shared.ts +62 -0
  108. package/src/components/features/video-bites-strip.tsx +739 -0
  109. package/src/components/features/video-ratio-tabs.tsx +15 -19
  110. package/src/components/features/video.tsx +265 -4
  111. package/src/components/onboarding-guides/onboarding-guide-detail-view.tsx +10 -9
  112. package/src/components/shared/product-release/release-detail-page.tsx +12 -18
  113. package/src/components/square-avatar.tsx +5 -2
  114. package/src/components/user-display.tsx +14 -4
  115. package/src/stories/VideoBitesStrip.stories.tsx +82 -0
  116. package/src/types/case-study.ts +23 -0
  117. package/src/types/video-processing.ts +11 -0
  118. package/dist/chunk-ANPV7GZT.js.map +0 -1
  119. package/dist/chunk-DKN425XR.cjs.map +0 -1
  120. package/dist/chunk-EULRZYQH.js.map +0 -1
  121. package/dist/chunk-GPTS2NXW.js.map +0 -1
  122. package/dist/chunk-RUHE3RJK.cjs.map +0 -1
  123. package/dist/chunk-SOAWTQXO.cjs.map +0 -1
  124. /package/dist/{chunk-OX75ZFN6.js.map → chunk-44Q3IXZM.js.map} +0 -0
  125. /package/dist/{chunk-MVY4ZKQR.js.map → chunk-4OLIDOLO.js.map} +0 -0
  126. /package/dist/{chunk-XGLZK25V.js.map → chunk-CQXHHVMR.js.map} +0 -0
  127. /package/dist/{chunk-3KA5L7LC.js.map → chunk-JSFHTIKA.js.map} +0 -0
  128. /package/dist/{chunk-DU2X2MH2.js.map → chunk-LFLFQ422.js.map} +0 -0
  129. /package/dist/{chunk-N2LW5NHS.js.map → chunk-M6ODI2F5.js.map} +0 -0
  130. /package/dist/{chunk-EHVRW3WB.js.map → chunk-NULFIYK2.js.map} +0 -0
  131. /package/dist/{chunk-OGNKGDHT.js.map → chunk-WIMXB5EU.js.map} +0 -0
  132. /package/dist/{chunk-3BPVTHOW.js.map → chunk-XGOX5UQ2.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.387",
3
+ "version": "0.0.388-snapshot.20260709030118",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -120,6 +120,12 @@
120
120
  "require": "./dist/components/features/mux-origins.cjs",
121
121
  "default": "./dist/components/features/mux-origins.js"
122
122
  },
123
+ "./components/features/video-bites-shared": {
124
+ "types": "./dist/components/features/video-bites-shared.d.ts",
125
+ "import": "./dist/components/features/video-bites-shared.js",
126
+ "require": "./dist/components/features/video-bites-shared.cjs",
127
+ "default": "./dist/components/features/video-bites-shared.js"
128
+ },
123
129
  "./components/toast": {
124
130
  "types": "./dist/components/toast/index.d.ts",
125
131
  "import": "./dist/components/toast/index.js",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { ComponentType } from 'react';
3
+ import React, { ComponentType } from 'react';
4
4
  import {
5
5
  Tabs,
6
6
  TabsList,
@@ -9,7 +9,8 @@ import {
9
9
  } from '../ui/tabs';
10
10
  import type { VideoTeaser } from '../../types/video-processing';
11
11
  import { Video } from './video';
12
- import { VideoBitesDisplay } from './video-bites-display';
12
+ import { VideoBitesStrip, type VideoBiteStripItem } from './video-bites-strip';
13
+ import { DEFAULT_VIDEO_BITES_TITLE, type VideoBiteStripProfile } from './video-bites-shared';
13
14
  import { SECTION_HEADING_CLASS } from '../layout/page-heading';
14
15
 
15
16
  /**
@@ -47,10 +48,20 @@ export interface EntityVideoSectionProps {
47
48
  videoSummary?: string | null;
48
49
  /** Video bites/teasers array. */
49
50
  videoBites?: VideoTeaser[];
50
- /** Title for the video bites section. */
51
+ /** Title for the video bites section. Default: 'Key Moments' (unified). */
51
52
  bitesTitle?: string;
52
53
  /** Whether to filter bites to published only. */
53
54
  filterPublishedBites?: boolean;
55
+ /** Section-level profile shown in the strip's hover overlay. */
56
+ bitesProfile?: VideoBiteStripProfile | null;
57
+ /** Navigation target for the strip's overlay footer (the origin entity). */
58
+ bitesHref?: string;
59
+ /** Custom node between the bites heading and the strip (description slot). */
60
+ bitesHeaderSlot?: React.ReactNode;
61
+ /** Navigation fallback for bites without their own href/onNavigate. */
62
+ onBiteNavigate?: (bite: VideoBiteStripItem, index: number) => void;
63
+ /** Marquee auto-scroll for the bites strip. Default true. */
64
+ bitesAutoScroll?: boolean;
54
65
  /** Markdown renderer component injected by the host app. */
55
66
  MarkdownRenderer?: ComponentType<MarkdownRendererProps>;
56
67
  /**
@@ -73,8 +84,13 @@ export function EntityVideoSection({
73
84
  title = 'Video',
74
85
  videoSummary,
75
86
  videoBites,
76
- bitesTitle = 'Video Highlights',
87
+ bitesTitle = DEFAULT_VIDEO_BITES_TITLE,
77
88
  filterPublishedBites = true,
89
+ bitesProfile = null,
90
+ bitesHref,
91
+ bitesHeaderSlot,
92
+ onBiteNavigate,
93
+ bitesAutoScroll = true,
78
94
  MarkdownRenderer,
79
95
  srtContent,
80
96
  captionsUrl,
@@ -165,10 +181,15 @@ export function EntityVideoSection({
165
181
  )}
166
182
 
167
183
  {videoBites && videoBites.length > 0 && (
168
- <VideoBitesDisplay
184
+ <VideoBitesStrip
169
185
  bites={videoBites}
170
186
  title={bitesTitle}
171
187
  filterPublished={filterPublishedBites}
188
+ profile={bitesProfile}
189
+ href={bitesHref}
190
+ headerSlot={bitesHeaderSlot}
191
+ onBiteNavigate={onBiteNavigate}
192
+ autoScroll={bitesAutoScroll}
172
193
  />
173
194
  )}
174
195
  </>
@@ -52,11 +52,14 @@ export * from './tags-selector'
52
52
  export * from './warning-block'
53
53
  // Unified video primitives — single source of truth across all platforms.
54
54
  // `<Video>` replaces the deleted `<VideoPlayer>` + `<YouTubeEmbed>` pair;
55
- // `<EntityVideoSection>` + `<VideoBitesDisplay>` are the public detail-page
56
- // wrappers; `video-ratio-tabs` exports the aspect-ratio grouping primitives
57
- // shared by the public bites grid and the admin editors.
55
+ // `<EntityVideoSection>` + `<VideoBitesStrip>` are the public detail-page
56
+ // wrappers (`<VideoBitesDisplay>` is a deprecated shim over the strip);
57
+ // `video-ratio-tabs` keeps the aspect-ratio primitives (tabs/grid = admin-only);
58
+ // `video-bites-shared` is the SERVER-SAFE leaf (also its own published subpath).
58
59
  export * from './video'
59
60
  export * from './video-ratio-tabs'
61
+ export * from './video-bites-shared'
62
+ export * from './video-bites-strip'
60
63
  export * from './video-bites-display'
61
64
  export * from './entity-video-section'
62
65
  export * from './use-video-warmup'
@@ -1,77 +1,22 @@
1
1
  "use client";
2
2
 
3
- import React, { useMemo } from 'react';
4
- import { Card } from '../ui/card';
5
- import { useNearViewport } from '../../hooks/use-near-viewport';
3
+ import React from 'react';
6
4
  import type { VideoTeaser } from '../../types/video-processing';
7
- import { Video } from './video';
8
- import { SECTION_HEADING_CLASS } from '../layout/page-heading';
9
- import {
10
- RatioTabs,
11
- groupByAspectRatio,
12
- detectAspectRatio,
13
- ratioToCategory,
14
- RATIO_DISPLAY_GRID_CLASS,
15
- type VideoTeaserWithRatio,
16
- type RatioCategory,
17
- } from './video-ratio-tabs';
5
+ import { VideoBitesStrip } from './video-bites-strip';
6
+ import { DEFAULT_VIDEO_BITES_TITLE } from './video-bites-shared';
18
7
 
19
8
  /**
20
- * <VideoBitesDisplay> public grid of short video bites grouped by
21
- * aspect ratio.
9
+ * @deprecated Transitional shim use `<VideoBitesStrip>` directly.
22
10
  *
23
- * Goes through `<Video>` for every clip bites are no longer carved
24
- * out to raw `<VideoPlayer>`. The previous carve-out existed because
25
- * react-player's hls.js bundle was ~80KB and short clips didn't need
26
- * adaptive bitrate. With MuxPlayer, the player loads its HLS engine
27
- * lazily — for a plain MP4 source it's a thin shell around `<video>`,
28
- * so the carve-out costs more in complexity than it saves in bytes.
11
+ * The old aspect-ratio GRID rendered here was replaced by the unified
12
+ * strip (`video-bites-strip.tsx`, Figma node 4033-90364). This wrapper
13
+ * keeps the historical 4-prop signature compiling for hub call sites
14
+ * between the lib version bump and the hub-side migration.
29
15
  *
30
- * `LazyBite` defers mount until the wrapper enters the IO `500px`
31
- * margin so off-screen bites don't even render their player. Same
32
- * `useNearViewport` singleton used everywhere else in the lib.
16
+ * Scheduled deletion: next lib minor after the hub migration's grep gate
17
+ * confirms zero consumers. Do NOT add features here.
33
18
  */
34
19
 
35
- // =============================================================================
36
- // LazyBite — viewport-gated wrapper for video bite cards
37
- // =============================================================================
38
-
39
- /** Aspect-ratio-aware placeholder prevents CLS across the grid. */
40
- const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {
41
- portrait: '9 / 16',
42
- square: '1 / 1',
43
- landscape: '16 / 9',
44
- };
45
-
46
- interface LazyBiteProps {
47
- /** Aspect ratio of the wrapped bite — placeholder keeps layout stable. */
48
- ratio: RatioCategory;
49
- /** Card rendered once the wrapper enters (within `500px` of) the viewport. */
50
- children: React.ReactNode;
51
- }
52
-
53
- /**
54
- * Defers mounting an off-screen video bite until it scrolls within
55
- * ~500px of the viewport. Renders an aspect-ratio-matched placeholder
56
- * beforehand so layout stays stable.
57
- *
58
- * Placeholder bg matches the wrapped `<Card>` background (`bg-ods-card`)
59
- * so the swap-in is visually seamless and avoids a flash on hydration.
60
- */
61
- function LazyBite({ ratio, children }: LazyBiteProps) {
62
- const { ref, isNear } = useNearViewport<HTMLDivElement>('500px');
63
-
64
- return (
65
- <div ref={ref} style={{ aspectRatio: RATIO_TO_CSS_ASPECT[ratio] }}>
66
- {isNear ? children : <div className="w-full h-full bg-ods-card rounded-md" />}
67
- </div>
68
- );
69
- }
70
-
71
- // =============================================================================
72
- // Public component
73
- // =============================================================================
74
-
75
20
  export interface VideoBitesDisplayProps {
76
21
  /** Array of video bites/teasers to display. */
77
22
  bites: VideoTeaser[];
@@ -83,135 +28,18 @@ export interface VideoBitesDisplayProps {
83
28
  showTitle?: boolean;
84
29
  }
85
30
 
86
- /**
87
- * Unified video-bites grid.
88
- *
89
- * Groups by aspect ratio when multiple ratios are present, otherwise
90
- * renders a flat grid. Each bite mounts lazily via `LazyBite` so
91
- * off-screen players don't cost the page.
92
- */
93
31
  export function VideoBitesDisplay({
94
32
  bites,
95
- title = 'Video Highlights',
33
+ title = DEFAULT_VIDEO_BITES_TITLE,
96
34
  filterPublished = true,
97
35
  showTitle = true,
98
36
  }: VideoBitesDisplayProps) {
99
- const grouped = useMemo(() => {
100
- const filtered = filterPublished ? bites.filter(b => b.published) : bites;
101
-
102
- const sorted = [...filtered].sort((a, b) => {
103
- if (!a.created_at && !b.created_at) return 0;
104
- if (!a.created_at) return 1;
105
- if (!b.created_at) return -1;
106
- return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
107
- });
108
-
109
- return groupByAspectRatio(sorted, b =>
110
- detectAspectRatio((b as VideoTeaserWithRatio).aspect_ratio),
111
- );
112
- }, [bites, filterPublished]);
113
-
114
- const totalCount = grouped.portrait.length + grouped.square.length + grouped.landscape.length;
115
- if (totalCount === 0) return null;
116
-
117
- return (
118
- <div className="flex flex-col gap-6 w-full min-w-0">
119
- {showTitle && (
120
- <h2 className={`${SECTION_HEADING_CLASS} break-words`}>
121
- {title}
122
- </h2>
123
- )}
124
-
125
- {grouped.hasMultiple ? (
126
- <RatioTabs
127
- groups={{
128
- portrait: {
129
- count: grouped.portrait.length,
130
- render: () => <BiteGrid bites={grouped.portrait} ratio="portrait" />,
131
- },
132
- square: {
133
- count: grouped.square.length,
134
- render: () => <BiteGrid bites={grouped.square} ratio="square" />,
135
- },
136
- landscape: {
137
- count: grouped.landscape.length,
138
- render: () => <BiteGrid bites={grouped.landscape} ratio="landscape" />,
139
- },
140
- }}
141
- />
142
- ) : (
143
- <BiteGrid
144
- bites={
145
- grouped.portrait.length > 0
146
- ? grouped.portrait
147
- : grouped.square.length > 0
148
- ? grouped.square
149
- : grouped.landscape
150
- }
151
- ratio={
152
- grouped.portrait.length > 0
153
- ? 'portrait'
154
- : grouped.square.length > 0
155
- ? 'square'
156
- : 'landscape'
157
- }
158
- />
159
- )}
160
- </div>
161
- );
162
- }
163
-
164
- // =============================================================================
165
- // Internals
166
- // =============================================================================
167
-
168
- /**
169
- * Renders a grid of bite cards with ratio-appropriate column layout.
170
- * Each card is wrapped in `LazyBite` so off-screen bites don't mount
171
- * their player until they scroll near the viewport.
172
- */
173
- function BiteGrid({ bites, ratio }: { bites: VideoTeaser[]; ratio: RatioCategory }) {
174
37
  return (
175
- <div className={RATIO_DISPLAY_GRID_CLASS[ratio]}>
176
- {bites.map((bite, index) => (
177
- <LazyBite key={bite.url || index} ratio={ratio}>
178
- <VideoBiteCard url={bite.url} title={bite.title} thumbnailUrl={bite.thumbnail_url} />
179
- </LazyBite>
180
- ))}
181
- </div>
38
+ <VideoBitesStrip
39
+ bites={bites}
40
+ title={title}
41
+ filterPublished={filterPublished}
42
+ showTitle={showTitle}
43
+ />
182
44
  );
183
45
  }
184
-
185
- interface VideoBiteCardProps {
186
- url: string;
187
- title?: string | null;
188
- thumbnailUrl?: string | null;
189
- }
190
-
191
- /**
192
- * Individual bite card — routes through `<Video>` so the SSoT player
193
- * is the only video primitive in the lib.
194
- *
195
- * Layout: `LazyBite` sets the OUTER `aspectRatio` (portrait/square/landscape),
196
- * but `<Card>` between LazyBite and `<Video>` has no intrinsic height, so
197
- * we wrap `<Video>` in `layout="fill"` + an explicit `relative` parent so
198
- * the player fills the bite's aspect box from first paint. Otherwise
199
- * MuxPlayer renders at its intrinsic default size and grows once metadata
200
- * loads — the same CLS that hits the centered layout.
201
- */
202
- function VideoBiteCard({ url, title, thumbnailUrl }: VideoBiteCardProps) {
203
- return (
204
- <Card className="overflow-hidden border border-ods-border bg-ods-card hover:border-ods-accent transition-colors flex flex-col h-full">
205
- <div className="relative flex-1 min-h-0">
206
- <Video url={url} poster={thumbnailUrl || undefined} layout="fill" />
207
- </div>
208
- {title && (
209
- <div className="p-4">
210
- <p className="text-h4 text-ods-text-primary line-clamp-2" title={title}>{title}</p>
211
- </div>
212
- )}
213
- </Card>
214
- );
215
- }
216
-
217
- export { VideoBiteCard };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * video-bites-shared — SERVER-SAFE leaf for the video-bites surface.
3
+ *
4
+ * No React imports, no 'use client'. Published as its own subpath
5
+ * (`@flamingo-stack/openframe-frontend-core/components/features/video-bites-shared`)
6
+ * so server-side consumers (the hub's featured-video-bites DAL) can import the
7
+ * title constant, profile adapter, and sort comparator WITHOUT pulling the
8
+ * 'use client' strip/MuxPlayer module graph into a server bundle — same
9
+ * pattern (and reason) as `mux-origins.ts`.
10
+ *
11
+ * Client components (`<VideoBitesStrip>`, the features barrel) re-export from
12
+ * here; there must never be a second definition of any of these.
13
+ */
14
+
15
+ import type { VideoTeaser } from '../../types/video-processing'
16
+
17
+ /** Single exported home for the unified public bites-section title. */
18
+ export const DEFAULT_VIDEO_BITES_TITLE = 'Key Moments'
19
+
20
+ /**
21
+ * Profile rendered in the strip's hover overlay. Structurally identical to
22
+ * `UserDisplayProps` — the overlay renders it via `<UserDisplay>` 1:1.
23
+ */
24
+ export interface VideoBiteStripProfile {
25
+ name: string
26
+ avatarUrl?: string | null
27
+ subtitle?: string | null
28
+ }
29
+
30
+ /**
31
+ * Adapter SSoT: builds a strip profile from the repo-wide author/user shape
32
+ * (`profiles` rows and hydrated authors all expose full_name / avatar_url /
33
+ * job_title). ALL strip call sites and the featured DAL's mapProfile route
34
+ * through this — never hand-roll the mapping.
35
+ *
36
+ * Returns NULL (never `{name: ''}`) when `full_name` is null/empty: callers
37
+ * rely on `profile ?? fallback` chains, and some producers (e.g. the release
38
+ * kit) pass null-field author stubs.
39
+ */
40
+ export function toStripProfile(
41
+ p?: { full_name?: string | null; avatar_url?: string | null; job_title?: string | null } | null,
42
+ fallbackSubtitle?: string | null,
43
+ ): VideoBiteStripProfile | null {
44
+ if (!p?.full_name) return null
45
+ return {
46
+ name: p.full_name,
47
+ avatarUrl: p.avatar_url ?? null,
48
+ subtitle: p.job_title ?? fallbackSubtitle ?? null,
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Null-safe created_at-desc COMPARATOR (missing dates sort last).
54
+ * Call as `[...arr].sort(sortBitesByCreatedAtDesc)` — shared by the strip,
55
+ * the hub admin editor, and the hub featured DAL.
56
+ */
57
+ export function sortBitesByCreatedAtDesc(a: VideoTeaser, b: VideoTeaser): number {
58
+ if (!a.created_at && !b.created_at) return 0
59
+ if (!a.created_at) return 1
60
+ if (!b.created_at) return -1
61
+ return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
62
+ }