@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.
Files changed (55) hide show
  1. package/dist/{chunk-AAX27BCR.js → chunk-DV2GT7RI.js} +3703 -4168
  2. package/dist/chunk-DV2GT7RI.js.map +1 -0
  3. package/dist/{chunk-L4T24AN4.cjs → chunk-JFGORTXV.cjs} +868 -1333
  4. package/dist/chunk-JFGORTXV.cjs.map +1 -0
  5. package/dist/components/chat/chat-message-list.d.ts.map +1 -1
  6. package/dist/components/features/entity-video-section.d.ts +54 -0
  7. package/dist/components/features/entity-video-section.d.ts.map +1 -0
  8. package/dist/components/features/index.cjs +18 -2
  9. package/dist/components/features/index.cjs.map +1 -1
  10. package/dist/components/features/index.d.ts +4 -2
  11. package/dist/components/features/index.d.ts.map +1 -1
  12. package/dist/components/features/index.js +21 -5
  13. package/dist/components/features/video-bites-display.d.ts +38 -0
  14. package/dist/components/features/video-bites-display.d.ts.map +1 -0
  15. package/dist/components/features/video-ratio-tabs.d.ts +62 -0
  16. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
  17. package/dist/components/features/video.d.ts +94 -0
  18. package/dist/components/features/video.d.ts.map +1 -0
  19. package/dist/components/index.cjs +18 -2
  20. package/dist/components/index.cjs.map +1 -1
  21. package/dist/components/index.js +21 -5
  22. package/dist/components/media-carousel.d.ts.map +1 -1
  23. package/dist/components/navigation/index.cjs +2 -2
  24. package/dist/components/navigation/index.js +1 -1
  25. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  26. package/dist/components/ui/index.cjs +2 -2
  27. package/dist/components/ui/index.js +1 -1
  28. package/dist/index.cjs +18 -2
  29. package/dist/index.cjs.map +1 -1
  30. package/dist/index.js +21 -5
  31. package/package.json +2 -2
  32. package/src/components/chat/chat-message-list.tsx +62 -18
  33. package/src/components/features/entity-video-section.tsx +175 -0
  34. package/src/components/features/index.ts +9 -2
  35. package/src/components/features/video-bites-display.tsx +216 -0
  36. package/src/components/features/video-ratio-tabs.tsx +174 -0
  37. package/src/components/features/video.tsx +474 -0
  38. package/src/components/media-carousel.tsx +43 -236
  39. package/src/components/shared/product-release/release-detail-page.tsx +26 -19
  40. package/dist/chunk-AAX27BCR.js.map +0 -1
  41. package/dist/chunk-L4T24AN4.cjs.map +0 -1
  42. package/dist/components/features/video-player.d.ts +0 -44
  43. package/dist/components/features/video-player.d.ts.map +0 -1
  44. package/dist/components/features/youtube-embed.d.ts +0 -31
  45. package/dist/components/features/youtube-embed.d.ts.map +0 -1
  46. package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
  47. package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
  48. package/dist/utils/lite-youtube-embed.d.ts +0 -9
  49. package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
  50. package/src/components/features/.video-player.md +0 -44
  51. package/src/components/features/.youtube-embed.md +0 -40
  52. package/src/components/features/video-player.tsx +0 -893
  53. package/src/components/features/youtube-embed.tsx +0 -158
  54. package/src/utils/lite-youtube-embed-stub.tsx +0 -21
  55. 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
- export * from './video-player'
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
+ }