@flamingo-stack/openframe-frontend-core 0.0.178 → 0.0.179-snapshot.20260514181702
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-AAX27BCR.js → chunk-DV2GT7RI.js} +3703 -4168
- package/dist/chunk-DV2GT7RI.js.map +1 -0
- package/dist/{chunk-L4T24AN4.cjs → chunk-JFGORTXV.cjs} +868 -1333
- package/dist/chunk-JFGORTXV.cjs.map +1 -0
- package/dist/components/chat/chat-message-list.d.ts.map +1 -1
- package/dist/components/features/entity-video-section.d.ts +54 -0
- package/dist/components/features/entity-video-section.d.ts.map +1 -0
- package/dist/components/features/index.cjs +18 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +4 -2
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +21 -5
- package/dist/components/features/video-bites-display.d.ts +38 -0
- package/dist/components/features/video-bites-display.d.ts.map +1 -0
- package/dist/components/features/video-ratio-tabs.d.ts +62 -0
- package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +94 -0
- package/dist/components/features/video.d.ts.map +1 -0
- package/dist/components/index.cjs +18 -2
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +21 -5
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +2 -2
- package/dist/components/ui/index.js +1 -1
- package/dist/index.cjs +18 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +21 -5
- package/package.json +2 -2
- package/src/components/chat/chat-message-list.tsx +62 -18
- package/src/components/features/entity-video-section.tsx +175 -0
- package/src/components/features/index.ts +9 -2
- package/src/components/features/video-bites-display.tsx +216 -0
- package/src/components/features/video-ratio-tabs.tsx +174 -0
- package/src/components/features/video.tsx +474 -0
- package/src/components/media-carousel.tsx +43 -236
- package/src/components/shared/product-release/release-detail-page.tsx +26 -19
- package/dist/chunk-AAX27BCR.js.map +0 -1
- package/dist/chunk-L4T24AN4.cjs.map +0 -1
- package/dist/components/features/video-player.d.ts +0 -44
- package/dist/components/features/video-player.d.ts.map +0 -1
- package/dist/components/features/youtube-embed.d.ts +0 -31
- package/dist/components/features/youtube-embed.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
- package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
- package/dist/utils/lite-youtube-embed.d.ts +0 -9
- package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
- package/src/components/features/.video-player.md +0 -44
- package/src/components/features/.youtube-embed.md +0 -40
- package/src/components/features/video-player.tsx +0 -893
- package/src/components/features/youtube-embed.tsx +0 -158
- package/src/utils/lite-youtube-embed-stub.tsx +0 -21
- package/src/utils/lite-youtube-embed.tsx +0 -46
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ComponentType } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Tabs,
|
|
6
|
+
TabsList,
|
|
7
|
+
TabsTrigger,
|
|
8
|
+
TabsContent,
|
|
9
|
+
} from '../ui/tabs';
|
|
10
|
+
import type { VideoTeaser } from '../../types/video-processing';
|
|
11
|
+
import { Video } from './video';
|
|
12
|
+
import { VideoBitesDisplay } from './video-bites-display';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* <EntityVideoSection> — public detail-page video block.
|
|
16
|
+
*
|
|
17
|
+
* Tabbed Full Video / Highlights when both exist, plus optional
|
|
18
|
+
* markdown summary + video bites grid. The actual video rendering
|
|
19
|
+
* (YouTube facade, Mux HLS, MP4 fallback) is delegated to `<Video>` —
|
|
20
|
+
* the single source of truth.
|
|
21
|
+
*
|
|
22
|
+
* YouTube takes precedence over the uploaded video when both
|
|
23
|
+
* `youtubeUrl` and `mainVideoUrl` are present. That precedence is
|
|
24
|
+
* resolved here (in the section wrapper) rather than inside `<Video>`,
|
|
25
|
+
* so the underlying primitive stays single-source-per-render.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
interface MarkdownRendererProps {
|
|
29
|
+
content: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EntityVideoSectionProps {
|
|
33
|
+
/** Main uploaded video URL. */
|
|
34
|
+
mainVideoUrl?: string | null;
|
|
35
|
+
/** YouTube URL (takes priority over `mainVideoUrl` for display). */
|
|
36
|
+
youtubeUrl?: string | null;
|
|
37
|
+
/** AI-generated highlight video URL. */
|
|
38
|
+
highlightVideoUrl?: string | null;
|
|
39
|
+
/** Thumbnail for highlight video. */
|
|
40
|
+
highlightVideoThumbnail?: string | null;
|
|
41
|
+
/** Poster/thumbnail for main video. */
|
|
42
|
+
mainVideoPoster?: string | null;
|
|
43
|
+
/** Title for YouTube embed. */
|
|
44
|
+
title?: string;
|
|
45
|
+
/** AI-generated video summary (markdown). */
|
|
46
|
+
videoSummary?: string | null;
|
|
47
|
+
/** Video bites/teasers array. */
|
|
48
|
+
videoBites?: VideoTeaser[];
|
|
49
|
+
/** Title for the video bites section. */
|
|
50
|
+
bitesTitle?: string;
|
|
51
|
+
/** Whether to filter bites to published only. */
|
|
52
|
+
filterPublishedBites?: boolean;
|
|
53
|
+
/** Markdown renderer component injected by the host app. */
|
|
54
|
+
MarkdownRenderer?: ComponentType<MarkdownRendererProps>;
|
|
55
|
+
/**
|
|
56
|
+
* Raw SRT content. Deprecated — pass `captionsUrl` instead.
|
|
57
|
+
* Forwarded to `<Video>` for the dev-only warning.
|
|
58
|
+
*/
|
|
59
|
+
srtContent?: string | null;
|
|
60
|
+
/** HTTPS URL to a VTT captions file (rendered as native `<track>`). */
|
|
61
|
+
captionsUrl?: string | null;
|
|
62
|
+
/** LCP hint — when true, the full-video tab's poster eager-loads. */
|
|
63
|
+
priority?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function EntityVideoSection({
|
|
67
|
+
mainVideoUrl,
|
|
68
|
+
youtubeUrl,
|
|
69
|
+
highlightVideoUrl,
|
|
70
|
+
highlightVideoThumbnail,
|
|
71
|
+
mainVideoPoster,
|
|
72
|
+
title = 'Video',
|
|
73
|
+
videoSummary,
|
|
74
|
+
videoBites,
|
|
75
|
+
bitesTitle = 'Video Highlights',
|
|
76
|
+
filterPublishedBites = true,
|
|
77
|
+
MarkdownRenderer,
|
|
78
|
+
srtContent,
|
|
79
|
+
captionsUrl,
|
|
80
|
+
priority = false,
|
|
81
|
+
}: EntityVideoSectionProps) {
|
|
82
|
+
const hasFullVideo = !!(youtubeUrl || mainVideoUrl);
|
|
83
|
+
const hasHighlight = !!highlightVideoUrl;
|
|
84
|
+
const hasVideo = hasFullVideo || hasHighlight;
|
|
85
|
+
|
|
86
|
+
if (!hasVideo && !videoSummary && (!videoBites || videoBites.length === 0)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// YouTube wins when both URLs are present.
|
|
91
|
+
const fullVideoUrl = youtubeUrl || mainVideoUrl || null;
|
|
92
|
+
const fullVideoKind: 'youtube' | 'auto' = youtubeUrl ? 'youtube' : 'auto';
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{hasVideo &&
|
|
97
|
+
(hasFullVideo && hasHighlight ? (
|
|
98
|
+
<Tabs defaultValue="full-video" className="w-full">
|
|
99
|
+
<TabsList className="inline-flex justify-start rounded-none bg-transparent h-auto p-0 gap-0">
|
|
100
|
+
<TabsTrigger
|
|
101
|
+
value="full-video"
|
|
102
|
+
className="rounded-none border-b-2 border-transparent data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 md:px-6 py-3 text-ods-text-secondary data-[state=active]:text-ods-text-primary"
|
|
103
|
+
>
|
|
104
|
+
Full Video
|
|
105
|
+
</TabsTrigger>
|
|
106
|
+
<TabsTrigger
|
|
107
|
+
value="highlights"
|
|
108
|
+
className="rounded-none border-b-2 border-transparent data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 md:px-6 py-3 text-ods-text-secondary data-[state=active]:text-ods-text-primary"
|
|
109
|
+
>
|
|
110
|
+
Highlights
|
|
111
|
+
</TabsTrigger>
|
|
112
|
+
</TabsList>
|
|
113
|
+
|
|
114
|
+
<TabsContent value="full-video" className="mt-4">
|
|
115
|
+
<Video
|
|
116
|
+
kind={fullVideoKind}
|
|
117
|
+
url={fullVideoUrl!}
|
|
118
|
+
poster={mainVideoPoster}
|
|
119
|
+
title={title}
|
|
120
|
+
srtContent={srtContent}
|
|
121
|
+
captionsUrl={captionsUrl}
|
|
122
|
+
layout="centered"
|
|
123
|
+
priority={priority}
|
|
124
|
+
/>
|
|
125
|
+
</TabsContent>
|
|
126
|
+
|
|
127
|
+
<TabsContent value="highlights" className="mt-4">
|
|
128
|
+
<Video
|
|
129
|
+
url={highlightVideoUrl!}
|
|
130
|
+
poster={highlightVideoThumbnail}
|
|
131
|
+
layout="centered"
|
|
132
|
+
/>
|
|
133
|
+
</TabsContent>
|
|
134
|
+
</Tabs>
|
|
135
|
+
) : hasFullVideo ? (
|
|
136
|
+
<Video
|
|
137
|
+
kind={fullVideoKind}
|
|
138
|
+
url={fullVideoUrl!}
|
|
139
|
+
poster={mainVideoPoster}
|
|
140
|
+
title={title}
|
|
141
|
+
srtContent={srtContent}
|
|
142
|
+
captionsUrl={captionsUrl}
|
|
143
|
+
layout="centered"
|
|
144
|
+
priority={priority}
|
|
145
|
+
/>
|
|
146
|
+
) : (
|
|
147
|
+
<Video
|
|
148
|
+
url={highlightVideoUrl!}
|
|
149
|
+
poster={highlightVideoThumbnail}
|
|
150
|
+
layout="centered"
|
|
151
|
+
priority={priority}
|
|
152
|
+
/>
|
|
153
|
+
))}
|
|
154
|
+
|
|
155
|
+
{videoSummary && MarkdownRenderer && (
|
|
156
|
+
<div className="flex flex-col gap-6 w-full min-w-0">
|
|
157
|
+
<h2 className="text-h1 tracking-[-1.12px] text-ods-text-primary break-words">
|
|
158
|
+
Summary
|
|
159
|
+
</h2>
|
|
160
|
+
<div className="text-h4 text-ods-text-primary break-words overflow-hidden">
|
|
161
|
+
<MarkdownRenderer content={videoSummary} />
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{videoBites && videoBites.length > 0 && (
|
|
167
|
+
<VideoBitesDisplay
|
|
168
|
+
bites={videoBites}
|
|
169
|
+
title={bitesTitle}
|
|
170
|
+
filterPublished={filterPublishedBites}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -33,7 +33,15 @@ export * from './social-links-manager'
|
|
|
33
33
|
export * from './start-with-openframe-button'
|
|
34
34
|
export * from './status-filter-component'
|
|
35
35
|
export * from './tags-selector'
|
|
36
|
-
|
|
36
|
+
// Unified video primitives — single source of truth across all platforms.
|
|
37
|
+
// `<Video>` replaces the deleted `<VideoPlayer>` + `<YouTubeEmbed>` pair;
|
|
38
|
+
// `<EntityVideoSection>` + `<VideoBitesDisplay>` are the public detail-page
|
|
39
|
+
// wrappers; `video-ratio-tabs` exports the aspect-ratio grouping primitives
|
|
40
|
+
// shared by the public bites grid and the admin editors.
|
|
41
|
+
export * from './video'
|
|
42
|
+
export * from './video-ratio-tabs'
|
|
43
|
+
export * from './video-bites-display'
|
|
44
|
+
export * from './entity-video-section'
|
|
37
45
|
export * from './video-source-selector'
|
|
38
46
|
export * from './transcript-summary-editor'
|
|
39
47
|
export * from './highlight-video-section'
|
|
@@ -46,7 +54,6 @@ export * from './highlight-video-preview'
|
|
|
46
54
|
export * from './transcribe-and-summarize-combined-section'
|
|
47
55
|
export * from './highlight-video-combined-section'
|
|
48
56
|
export * from './view-toggle'
|
|
49
|
-
export * from './youtube-embed'
|
|
50
57
|
// AI Enrich components
|
|
51
58
|
export * from './ai-enrich'
|
|
52
59
|
export * from './policy-configuration-panel'
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useMemo } from 'react';
|
|
4
|
+
import { Card } from '../ui/card';
|
|
5
|
+
import { useNearViewport } from '../../hooks/use-near-viewport';
|
|
6
|
+
import type { VideoTeaser } from '../../types/video-processing';
|
|
7
|
+
import { Video } from './video';
|
|
8
|
+
import {
|
|
9
|
+
RatioTabs,
|
|
10
|
+
groupByAspectRatio,
|
|
11
|
+
detectAspectRatio,
|
|
12
|
+
ratioToCategory,
|
|
13
|
+
RATIO_DISPLAY_GRID_CLASS,
|
|
14
|
+
type VideoTeaserWithRatio,
|
|
15
|
+
type RatioCategory,
|
|
16
|
+
} from './video-ratio-tabs';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* <VideoBitesDisplay> — public grid of short video bites grouped by
|
|
20
|
+
* aspect ratio.
|
|
21
|
+
*
|
|
22
|
+
* Goes through `<Video>` for every clip — bites are no longer carved
|
|
23
|
+
* out to raw `<VideoPlayer>`. The previous carve-out existed because
|
|
24
|
+
* react-player's hls.js bundle was ~80KB and short clips didn't need
|
|
25
|
+
* adaptive bitrate. With MuxPlayer, the player loads its HLS engine
|
|
26
|
+
* lazily — for a plain MP4 source it's a thin shell around `<video>`,
|
|
27
|
+
* so the carve-out costs more in complexity than it saves in bytes.
|
|
28
|
+
*
|
|
29
|
+
* `LazyBite` defers mount until the wrapper enters the IO `500px`
|
|
30
|
+
* margin so off-screen bites don't even render their player. Same
|
|
31
|
+
* `useNearViewport` singleton used everywhere else in the lib.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// LazyBite — viewport-gated wrapper for video bite cards
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
/** Aspect-ratio-aware placeholder prevents CLS across the grid. */
|
|
39
|
+
const RATIO_TO_CSS_ASPECT: Record<RatioCategory, string> = {
|
|
40
|
+
portrait: '9 / 16',
|
|
41
|
+
square: '1 / 1',
|
|
42
|
+
landscape: '16 / 9',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
interface LazyBiteProps {
|
|
46
|
+
/** Aspect ratio of the wrapped bite — placeholder keeps layout stable. */
|
|
47
|
+
ratio: RatioCategory;
|
|
48
|
+
/** Card rendered once the wrapper enters (within `500px` of) the viewport. */
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Defers mounting an off-screen video bite until it scrolls within
|
|
54
|
+
* ~500px of the viewport. Renders an aspect-ratio-matched placeholder
|
|
55
|
+
* beforehand so layout stays stable.
|
|
56
|
+
*
|
|
57
|
+
* Placeholder bg matches the wrapped `<Card>` background (`bg-ods-card`)
|
|
58
|
+
* so the swap-in is visually seamless and avoids a flash on hydration.
|
|
59
|
+
*/
|
|
60
|
+
function LazyBite({ ratio, children }: LazyBiteProps) {
|
|
61
|
+
const { ref, isNear } = useNearViewport<HTMLDivElement>('500px');
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div ref={ref} style={{ aspectRatio: RATIO_TO_CSS_ASPECT[ratio] }}>
|
|
65
|
+
{isNear ? children : <div className="w-full h-full bg-ods-card rounded-md" />}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// =============================================================================
|
|
71
|
+
// Public component
|
|
72
|
+
// =============================================================================
|
|
73
|
+
|
|
74
|
+
export interface VideoBitesDisplayProps {
|
|
75
|
+
/** Array of video bites/teasers to display. */
|
|
76
|
+
bites: VideoTeaser[];
|
|
77
|
+
/** Title for the section. */
|
|
78
|
+
title?: string;
|
|
79
|
+
/** Whether to filter to only show published bites. Default `true`. */
|
|
80
|
+
filterPublished?: boolean;
|
|
81
|
+
/** Whether to show the title section heading. Default `true`. */
|
|
82
|
+
showTitle?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Unified video-bites grid.
|
|
87
|
+
*
|
|
88
|
+
* Groups by aspect ratio when multiple ratios are present, otherwise
|
|
89
|
+
* renders a flat grid. Each bite mounts lazily via `LazyBite` so
|
|
90
|
+
* off-screen players don't cost the page.
|
|
91
|
+
*/
|
|
92
|
+
export function VideoBitesDisplay({
|
|
93
|
+
bites,
|
|
94
|
+
title = 'Video Highlights',
|
|
95
|
+
filterPublished = true,
|
|
96
|
+
showTitle = true,
|
|
97
|
+
}: VideoBitesDisplayProps) {
|
|
98
|
+
const grouped = useMemo(() => {
|
|
99
|
+
const filtered = filterPublished ? bites.filter(b => b.published) : bites;
|
|
100
|
+
|
|
101
|
+
const sorted = [...filtered].sort((a, b) => {
|
|
102
|
+
if (!a.created_at && !b.created_at) return 0;
|
|
103
|
+
if (!a.created_at) return 1;
|
|
104
|
+
if (!b.created_at) return -1;
|
|
105
|
+
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return groupByAspectRatio(sorted, b =>
|
|
109
|
+
detectAspectRatio((b as VideoTeaserWithRatio).aspect_ratio),
|
|
110
|
+
);
|
|
111
|
+
}, [bites, filterPublished]);
|
|
112
|
+
|
|
113
|
+
const totalCount = grouped.portrait.length + grouped.square.length + grouped.landscape.length;
|
|
114
|
+
if (totalCount === 0) return null;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="flex flex-col gap-6 w-full min-w-0">
|
|
118
|
+
{showTitle && (
|
|
119
|
+
<h2 className="text-h1 tracking-[-1.12px] text-ods-text-primary break-words">
|
|
120
|
+
{title}
|
|
121
|
+
</h2>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
{grouped.hasMultiple ? (
|
|
125
|
+
<RatioTabs
|
|
126
|
+
groups={{
|
|
127
|
+
portrait: {
|
|
128
|
+
count: grouped.portrait.length,
|
|
129
|
+
render: () => <BiteGrid bites={grouped.portrait} ratio="portrait" />,
|
|
130
|
+
},
|
|
131
|
+
square: {
|
|
132
|
+
count: grouped.square.length,
|
|
133
|
+
render: () => <BiteGrid bites={grouped.square} ratio="square" />,
|
|
134
|
+
},
|
|
135
|
+
landscape: {
|
|
136
|
+
count: grouped.landscape.length,
|
|
137
|
+
render: () => <BiteGrid bites={grouped.landscape} ratio="landscape" />,
|
|
138
|
+
},
|
|
139
|
+
}}
|
|
140
|
+
/>
|
|
141
|
+
) : (
|
|
142
|
+
<BiteGrid
|
|
143
|
+
bites={
|
|
144
|
+
grouped.portrait.length > 0
|
|
145
|
+
? grouped.portrait
|
|
146
|
+
: grouped.square.length > 0
|
|
147
|
+
? grouped.square
|
|
148
|
+
: grouped.landscape
|
|
149
|
+
}
|
|
150
|
+
ratio={
|
|
151
|
+
grouped.portrait.length > 0
|
|
152
|
+
? 'portrait'
|
|
153
|
+
: grouped.square.length > 0
|
|
154
|
+
? 'square'
|
|
155
|
+
: 'landscape'
|
|
156
|
+
}
|
|
157
|
+
/>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// Internals
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Renders a grid of bite cards with ratio-appropriate column layout.
|
|
169
|
+
* Each card is wrapped in `LazyBite` so off-screen bites don't mount
|
|
170
|
+
* their player until they scroll near the viewport.
|
|
171
|
+
*/
|
|
172
|
+
function BiteGrid({ bites, ratio }: { bites: VideoTeaser[]; ratio: RatioCategory }) {
|
|
173
|
+
return (
|
|
174
|
+
<div className={RATIO_DISPLAY_GRID_CLASS[ratio]}>
|
|
175
|
+
{bites.map((bite, index) => (
|
|
176
|
+
<LazyBite key={bite.url || index} ratio={ratio}>
|
|
177
|
+
<VideoBiteCard url={bite.url} title={bite.title} thumbnailUrl={bite.thumbnail_url} />
|
|
178
|
+
</LazyBite>
|
|
179
|
+
))}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface VideoBiteCardProps {
|
|
185
|
+
url: string;
|
|
186
|
+
title?: string | null;
|
|
187
|
+
thumbnailUrl?: string | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Individual bite card — routes through `<Video>` so the SSoT player
|
|
192
|
+
* is the only video primitive in the lib.
|
|
193
|
+
*
|
|
194
|
+
* Layout: `LazyBite` sets the OUTER `aspectRatio` (portrait/square/landscape),
|
|
195
|
+
* but `<Card>` between LazyBite and `<Video>` has no intrinsic height, so
|
|
196
|
+
* we wrap `<Video>` in `layout="fill"` + an explicit `relative` parent so
|
|
197
|
+
* the player fills the bite's aspect box from first paint. Otherwise
|
|
198
|
+
* MuxPlayer renders at its intrinsic default size and grows once metadata
|
|
199
|
+
* loads — the same CLS that hits the centered layout.
|
|
200
|
+
*/
|
|
201
|
+
function VideoBiteCard({ url, title, thumbnailUrl }: VideoBiteCardProps) {
|
|
202
|
+
return (
|
|
203
|
+
<Card className="overflow-hidden border border-ods-border bg-ods-card hover:border-ods-accent transition-colors flex flex-col h-full">
|
|
204
|
+
<div className="relative flex-1 min-h-0">
|
|
205
|
+
<Video url={url} poster={thumbnailUrl || undefined} layout="fill" />
|
|
206
|
+
</div>
|
|
207
|
+
{title && (
|
|
208
|
+
<div className="p-4">
|
|
209
|
+
<p className="text-h4 text-ods-text-primary line-clamp-2">{title}</p>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</Card>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export { VideoBiteCard };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aspect-ratio tab + grouping primitives for video grids.
|
|
5
|
+
*
|
|
6
|
+
* Used by `<VideoBitesDisplay>` and by app-level admin editors
|
|
7
|
+
* (VideoBitesEditor, VideoLibraryGrid) so portrait / square / landscape
|
|
8
|
+
* clips render in cohesive groups.
|
|
9
|
+
*
|
|
10
|
+
* Why these live here in the lib (not the hub): `<VideoBitesDisplay>`
|
|
11
|
+
* was moved into the lib for SSoT, and it depends on these primitives.
|
|
12
|
+
* The hub's admin editors re-export from here.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
Tabs,
|
|
17
|
+
TabsList,
|
|
18
|
+
TabsTrigger,
|
|
19
|
+
TabsContent,
|
|
20
|
+
} from '../ui/tabs';
|
|
21
|
+
import type { VideoTeaser } from '../../types/video-processing';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Vizard clip extraction aspect ratios. Mirrors `lib/types/aspect-ratio.ts`
|
|
25
|
+
* in the hub — narrow on purpose. If the hub's `VizardAspectRatio` adds a
|
|
26
|
+
* value, mirror it here.
|
|
27
|
+
*/
|
|
28
|
+
export type VizardAspectRatio = '9:16' | '16:9' | '1:1';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extended VideoTeaser with aspect_ratio metadata from Vizard.
|
|
32
|
+
* The lib `VideoTeaser` is the canonical type but doesn't include
|
|
33
|
+
* `aspect_ratio` (stored in JSONB, preserved through spreads).
|
|
34
|
+
* Import this when you need the ratio at compile time.
|
|
35
|
+
*/
|
|
36
|
+
export interface VideoTeaserWithRatio extends VideoTeaser {
|
|
37
|
+
aspect_ratio?: VizardAspectRatio;
|
|
38
|
+
confidence?: number;
|
|
39
|
+
viral_reason?: string;
|
|
40
|
+
start_time_ms?: number;
|
|
41
|
+
end_time_ms?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Ratio category used for grid layout and tab grouping. */
|
|
45
|
+
export type RatioCategory = 'portrait' | 'square' | 'landscape';
|
|
46
|
+
|
|
47
|
+
// Shared tab trigger class — matches `<EntityVideoSection>`'s pattern.
|
|
48
|
+
const TAB_TRIGGER_CLASS =
|
|
49
|
+
'rounded-none border-b-2 border-transparent data-[state=active]:border-ods-accent data-[state=active]:bg-transparent data-[state=active]:shadow-none px-4 py-2 text-sm text-ods-text-secondary data-[state=active]:text-ods-text-primary';
|
|
50
|
+
|
|
51
|
+
/** Grid class for each aspect ratio (admin editors — narrower columns). */
|
|
52
|
+
export const RATIO_GRID_CLASS: Record<RatioCategory, string> = {
|
|
53
|
+
portrait: 'grid grid-cols-2 md:grid-cols-3 gap-4',
|
|
54
|
+
square: 'grid grid-cols-2 md:grid-cols-3 gap-4',
|
|
55
|
+
landscape: 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Grid class for public display (wider grids on detail pages). */
|
|
59
|
+
export const RATIO_DISPLAY_GRID_CLASS: Record<RatioCategory, string> = {
|
|
60
|
+
portrait: 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4',
|
|
61
|
+
square: 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4',
|
|
62
|
+
landscape: 'grid grid-cols-1 md:grid-cols-2 gap-6',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const RATIO_TAB_CONFIG: { key: RatioCategory; label: string }[] = [
|
|
66
|
+
{ key: 'portrait', label: 'Portrait 9:16' },
|
|
67
|
+
{ key: 'square', label: 'Square 1:1' },
|
|
68
|
+
{ key: 'landscape', label: 'Landscape 16:9' },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
interface RatioTabsProps {
|
|
72
|
+
groups: Record<RatioCategory, { count: number; render: () => React.ReactNode }>;
|
|
73
|
+
defaultTab?: RatioCategory;
|
|
74
|
+
className?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* RatioTabs — shared aspect-ratio tab wrapper.
|
|
79
|
+
*
|
|
80
|
+
* Only renders tabs that have content. `forceMount` + `data-[state=inactive]:hidden`
|
|
81
|
+
* keeps inactive tabs in the DOM so switching back doesn't scroll-jump.
|
|
82
|
+
*/
|
|
83
|
+
export function RatioTabs({
|
|
84
|
+
groups,
|
|
85
|
+
defaultTab,
|
|
86
|
+
className = '',
|
|
87
|
+
}: RatioTabsProps) {
|
|
88
|
+
const activeTabs = RATIO_TAB_CONFIG.filter(t => groups[t.key].count > 0);
|
|
89
|
+
|
|
90
|
+
// If only one tab has content, don't show tabs.
|
|
91
|
+
if (activeTabs.length <= 1) {
|
|
92
|
+
const active = activeTabs[0];
|
|
93
|
+
return active ? <>{groups[active.key].render()}</> : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const firstTab =
|
|
97
|
+
defaultTab && groups[defaultTab].count > 0 ? defaultTab : activeTabs[0].key;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<Tabs defaultValue={firstTab} className={`w-full ${className}`}>
|
|
101
|
+
<TabsList className="inline-flex justify-start rounded-none bg-transparent h-auto p-0 gap-0 mb-2">
|
|
102
|
+
{activeTabs.map(t => (
|
|
103
|
+
<TabsTrigger key={t.key} value={t.key} className={TAB_TRIGGER_CLASS}>
|
|
104
|
+
{t.label} ({groups[t.key].count})
|
|
105
|
+
</TabsTrigger>
|
|
106
|
+
))}
|
|
107
|
+
</TabsList>
|
|
108
|
+
{activeTabs.map(t => (
|
|
109
|
+
<TabsContent
|
|
110
|
+
key={t.key}
|
|
111
|
+
value={t.key}
|
|
112
|
+
forceMount
|
|
113
|
+
className="data-[state=inactive]:hidden"
|
|
114
|
+
>
|
|
115
|
+
{groups[t.key].render()}
|
|
116
|
+
</TabsContent>
|
|
117
|
+
))}
|
|
118
|
+
</Tabs>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Detect aspect ratio from a Vizard ratio string, falling back to
|
|
124
|
+
* inferring from width/height if the string is missing or unknown.
|
|
125
|
+
* Default: portrait (`'9:16'`).
|
|
126
|
+
*/
|
|
127
|
+
export function detectAspectRatio(
|
|
128
|
+
ratioString?: string,
|
|
129
|
+
width?: number,
|
|
130
|
+
height?: number,
|
|
131
|
+
): VizardAspectRatio {
|
|
132
|
+
if (ratioString === '16:9') return '16:9';
|
|
133
|
+
if (ratioString === '1:1') return '1:1';
|
|
134
|
+
if (ratioString === '9:16') return '9:16';
|
|
135
|
+
if (width && height) {
|
|
136
|
+
if (Math.abs(width - height) < Math.min(width, height) * 0.1) return '1:1';
|
|
137
|
+
if (width > height) return '16:9';
|
|
138
|
+
}
|
|
139
|
+
return '9:16';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Map a `VizardAspectRatio` to its `RatioCategory` for grouping. */
|
|
143
|
+
export function ratioToCategory(ratio: VizardAspectRatio): RatioCategory {
|
|
144
|
+
if (ratio === '16:9') return 'landscape';
|
|
145
|
+
if (ratio === '1:1') return 'square';
|
|
146
|
+
return 'portrait';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Group items by aspect ratio into portrait / square / landscape buckets.
|
|
151
|
+
* `hasMultiple` is true when 2+ buckets are non-empty (drives tab vs. flat
|
|
152
|
+
* grid rendering downstream).
|
|
153
|
+
*/
|
|
154
|
+
export function groupByAspectRatio<T>(
|
|
155
|
+
items: T[],
|
|
156
|
+
getAspectRatio: (item: T) => VizardAspectRatio,
|
|
157
|
+
): {
|
|
158
|
+
portrait: T[];
|
|
159
|
+
square: T[];
|
|
160
|
+
landscape: T[];
|
|
161
|
+
hasMultiple: boolean;
|
|
162
|
+
} {
|
|
163
|
+
const portrait: T[] = [];
|
|
164
|
+
const square: T[] = [];
|
|
165
|
+
const landscape: T[] = [];
|
|
166
|
+
for (const item of items) {
|
|
167
|
+
const cat = ratioToCategory(getAspectRatio(item));
|
|
168
|
+
if (cat === 'landscape') landscape.push(item);
|
|
169
|
+
else if (cat === 'square') square.push(item);
|
|
170
|
+
else portrait.push(item);
|
|
171
|
+
}
|
|
172
|
+
const filled = [portrait, square, landscape].filter(a => a.length > 0).length;
|
|
173
|
+
return { portrait, square, landscape, hasMultiple: filled > 1 };
|
|
174
|
+
}
|