@flamingo-stack/openframe-frontend-core 0.0.407-snapshot.20260711133234 → 0.0.407
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-E4J5KKHH.js → chunk-43H2WOAW.js} +2 -2
- package/dist/{chunk-5HOII3WB.js → chunk-4UZH3MT2.js} +2 -2
- package/dist/{chunk-RL344I7V.cjs → chunk-5M5W6XDY.cjs} +7 -7
- package/dist/{chunk-RL344I7V.cjs.map → chunk-5M5W6XDY.cjs.map} +1 -1
- package/dist/{chunk-KRLBVBYU.cjs → chunk-74FWQI73.cjs} +11 -11
- package/dist/{chunk-KRLBVBYU.cjs.map → chunk-74FWQI73.cjs.map} +1 -1
- package/dist/{chunk-ZF5O3KUQ.js → chunk-7XVFSGQA.js} +2 -2
- package/dist/{chunk-IVM47EGI.js → chunk-CVYSW33D.js} +109 -149
- package/dist/chunk-CVYSW33D.js.map +1 -0
- package/dist/{chunk-NEMUCNCG.cjs → chunk-DOBNDDNE.cjs} +59 -59
- package/dist/{chunk-NEMUCNCG.cjs.map → chunk-DOBNDDNE.cjs.map} +1 -1
- package/dist/{chunk-SOALFYU6.cjs → chunk-GGDM5J4B.cjs} +109 -149
- package/dist/chunk-GGDM5J4B.cjs.map +1 -0
- package/dist/{chunk-O5W26SCL.js → chunk-GJSLKATJ.js} +2 -2
- package/dist/{chunk-ZMFQWMM7.js → chunk-GMZL4T7C.js} +2 -2
- package/dist/{chunk-LQWYXIKB.js → chunk-H66L2265.js} +4 -4
- package/dist/{chunk-KZPVN7AZ.js → chunk-HB63TQQO.js} +2 -2
- package/dist/{chunk-VUW4G6SU.js → chunk-HJIRG7R6.js} +2 -2
- package/dist/{chunk-J6M2G6TD.js → chunk-KLBEIJVZ.js} +4 -4
- package/dist/{chunk-YRKPMYI3.cjs → chunk-LC6RRLFA.cjs} +15 -15
- package/dist/{chunk-YRKPMYI3.cjs.map → chunk-LC6RRLFA.cjs.map} +1 -1
- package/dist/{chunk-F4KE5AZO.cjs → chunk-LPC3C7MG.cjs} +37 -37
- package/dist/{chunk-F4KE5AZO.cjs.map → chunk-LPC3C7MG.cjs.map} +1 -1
- package/dist/{chunk-IK5AR6QG.js → chunk-NSF7JUXF.js} +4 -4
- package/dist/{chunk-T6ZUBXPA.cjs → chunk-PRKMOF2U.cjs} +9 -9
- package/dist/{chunk-T6ZUBXPA.cjs.map → chunk-PRKMOF2U.cjs.map} +1 -1
- package/dist/{chunk-RPGFIUN3.cjs → chunk-Q2MC3CBI.cjs} +26 -26
- package/dist/{chunk-RPGFIUN3.cjs.map → chunk-Q2MC3CBI.cjs.map} +1 -1
- package/dist/{chunk-KPGKNUJF.cjs → chunk-R4MXLJUS.cjs} +31 -31
- package/dist/{chunk-KPGKNUJF.cjs.map → chunk-R4MXLJUS.cjs.map} +1 -1
- package/dist/{chunk-A3RW7M6B.cjs → chunk-SY3QS2E5.cjs} +26 -26
- package/dist/{chunk-A3RW7M6B.cjs.map → chunk-SY3QS2E5.cjs.map} +1 -1
- package/dist/{chunk-V3EZQWHQ.cjs → chunk-WCPUJA3Y.cjs} +5 -5
- package/dist/{chunk-V3EZQWHQ.cjs.map → chunk-WCPUJA3Y.cjs.map} +1 -1
- package/dist/{chunk-L6OPVKVR.js → chunk-XFP43YKG.js} +4 -4
- package/dist/{chunk-WGOATV3P.cjs → chunk-YX7C5JD7.cjs} +62 -62
- package/dist/{chunk-WGOATV3P.cjs.map → chunk-YX7C5JD7.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -9
- package/dist/components/case-studies/index.cjs.map +1 -1
- package/dist/components/case-studies/index.js +2 -3
- package/dist/components/case-studies/index.js.map +1 -1
- package/dist/components/chat/index.cjs +2 -3
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +1 -2
- package/dist/components/chat/quick-action-chip.d.ts +2 -11
- package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -4
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +2 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +3 -4
- package/dist/components/embeds/index.cjs.map +1 -1
- package/dist/components/embeds/index.js +2 -3
- package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +3 -4
- package/dist/components/faq/index.cjs.map +1 -1
- package/dist/components/faq/index.js +2 -3
- package/dist/components/features/index.cjs +2 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +1 -2
- package/dist/components/help-center-pages/index.cjs +21 -21
- package/dist/components/help-center-pages/index.js +12 -12
- package/dist/components/index.cjs +126 -126
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.d.ts +0 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +10 -10
- package/dist/components/index.js.map +1 -1
- package/dist/components/media-carousel.d.ts +4 -28
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +2 -3
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +1 -2
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/related-content/index.cjs +3 -4
- package/dist/components/related-content/index.cjs.map +1 -1
- package/dist/components/related-content/index.js +2 -3
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +2 -3
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +1 -2
- package/dist/components/ui/tag.d.ts +2 -3
- package/dist/components/ui/tag.d.ts.map +1 -1
- package/dist/index.cjs +2 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -2
- package/dist/utils/media-carousel-utils-stub.d.ts +1 -3
- package/dist/utils/media-carousel-utils-stub.d.ts.map +1 -1
- package/dist/utils/media-carousel-utils.d.ts +10 -0
- package/dist/utils/media-carousel-utils.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/chat/quick-action-chip.tsx +4 -18
- package/src/components/embeds/reddit-embed-client.tsx +3 -0
- package/src/components/index.ts +0 -5
- package/src/components/media-carousel.tsx +149 -195
- package/src/components/ui/tag.tsx +3 -21
- package/src/styles/ods-design-tokens.css +0 -2
- package/src/utils/.media-carousel-utils.md +31 -0
- package/src/utils/media-carousel-utils-stub.ts +1 -3
- package/src/utils/media-carousel-utils.ts +22 -0
- package/dist/chunk-IVM47EGI.js.map +0 -1
- package/dist/chunk-SOALFYU6.cjs.map +0 -1
- /package/dist/{chunk-E4J5KKHH.js.map → chunk-43H2WOAW.js.map} +0 -0
- /package/dist/{chunk-5HOII3WB.js.map → chunk-4UZH3MT2.js.map} +0 -0
- /package/dist/{chunk-ZF5O3KUQ.js.map → chunk-7XVFSGQA.js.map} +0 -0
- /package/dist/{chunk-O5W26SCL.js.map → chunk-GJSLKATJ.js.map} +0 -0
- /package/dist/{chunk-ZMFQWMM7.js.map → chunk-GMZL4T7C.js.map} +0 -0
- /package/dist/{chunk-LQWYXIKB.js.map → chunk-H66L2265.js.map} +0 -0
- /package/dist/{chunk-KZPVN7AZ.js.map → chunk-HB63TQQO.js.map} +0 -0
- /package/dist/{chunk-VUW4G6SU.js.map → chunk-HJIRG7R6.js.map} +0 -0
- /package/dist/{chunk-J6M2G6TD.js.map → chunk-KLBEIJVZ.js.map} +0 -0
- /package/dist/{chunk-IK5AR6QG.js.map → chunk-NSF7JUXF.js.map} +0 -0
- /package/dist/{chunk-L6OPVKVR.js.map → chunk-XFP43YKG.js.map} +0 -0
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useRef, useEffect, memo, useCallback
|
|
3
|
+
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
|
4
4
|
import { cn } from "../utils/cn";
|
|
5
5
|
import { MediaItem } from '../utils/media-carousel-utils-stub';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { Video } from './features/video';
|
|
9
|
-
import { ImageOffIcon } from './icons-v2-generated/audio-and-visual/image-off-icon';
|
|
6
|
+
import { Video, extractYouTubeId } from './features/video';
|
|
7
|
+
import { VideoPlayBadge } from './features/video-center-badge';
|
|
10
8
|
|
|
11
9
|
// Navigation icons
|
|
12
10
|
const ChevronLeftIcon = () => (
|
|
@@ -21,79 +19,58 @@ const ChevronRightIcon = () => (
|
|
|
21
19
|
</svg>
|
|
22
20
|
);
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
* THE media carousel (Figma 4033:90570) — the single slide viewer for the
|
|
26
|
-
* whole codebase: OpenFrame homepage categories, openmsp vendor screenshots,
|
|
27
|
-
* who-am-i profiles, reddit embeds. One viewport + dot pagination; no
|
|
28
|
-
* thumbnail rail, no slide counter (both removed with the 2026-07 redesign).
|
|
29
|
-
*
|
|
30
|
-
* - Slides render through the SSoT `<Video>` (MuxPlayer / lite-youtube
|
|
31
|
-
* facade) for `video`/`youtube` items and a broken-image-resilient
|
|
32
|
-
* `<Image>` shim for `image` items.
|
|
33
|
-
* - Dots: 24px hit target, 8px dot, active = flamingo pink (per design).
|
|
34
|
-
* - Hover-revealed prev/next arrows (`showArrows`), keyboard arrows, and
|
|
35
|
-
* touch swipe all preserved from the previous generation.
|
|
36
|
-
*/
|
|
37
|
-
export interface MediaCarouselProps {
|
|
22
|
+
interface MediaCarouselProps {
|
|
38
23
|
media: MediaItem[];
|
|
39
24
|
className?: string;
|
|
40
25
|
/** Use aspect ratio instead of height classes for stable dimensions */
|
|
41
26
|
aspectRatio?: '16/9' | '4/3' | '3/2' | '1/1';
|
|
42
|
-
|
|
43
|
-
|
|
27
|
+
showThumbnails?: boolean;
|
|
28
|
+
autoPlay?: boolean;
|
|
44
29
|
/** How content should fit within the container */
|
|
45
30
|
objectFit?: 'contain' | 'cover';
|
|
46
|
-
/** When true, the first slide's image or YouTube poster uses `priority` / eager load (e.g. vendor detail LCP). */
|
|
47
|
-
posterPriority?: boolean;
|
|
48
|
-
/** Optional per-host rewrite for image URLs (e.g. the hub proxies plain
|
|
49
|
-
* `http://` sources to avoid mixed content). Applied to image slide srcs
|
|
50
|
-
* and derived thumbnails only — video/youtube URLs go to `<Video>` raw. */
|
|
51
|
-
transformImageSrc?: (url: string) => string;
|
|
52
|
-
/** Always reserve the dot-row height (24px) even when there is nothing to
|
|
53
|
-
* paginate — for surfaces that swap media sets in place (the OpenFrame
|
|
54
|
-
* categories showcase), so 1-slide vs N-slide sets never shift layout.
|
|
55
|
-
* Default false: standalone carousels (vendor page) collapse the row. */
|
|
56
|
-
reserveDotRow?: boolean;
|
|
57
31
|
}
|
|
58
32
|
|
|
59
|
-
export const MediaCarousel = memo(function MediaCarousel({
|
|
60
|
-
media,
|
|
33
|
+
export const MediaCarousel = memo(function MediaCarousel({
|
|
34
|
+
media,
|
|
61
35
|
className,
|
|
62
36
|
aspectRatio = "16/9",
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
transformImageSrc,
|
|
67
|
-
reserveDotRow = false,
|
|
37
|
+
showThumbnails = true,
|
|
38
|
+
autoPlay = false,
|
|
39
|
+
objectFit = 'contain'
|
|
68
40
|
}: MediaCarouselProps) {
|
|
69
41
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
70
42
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
|
71
43
|
const [touchEnd, setTouchEnd] = useState<number | null>(null);
|
|
72
|
-
/** Resource URLs that failed to load (e.g. 404, blocked CDN) */
|
|
73
|
-
const [brokenResourceUrls, setBrokenResourceUrls] = useState<Record<string, true>>({});
|
|
74
|
-
/** The src whose <Image> finished loading — keyed by URL (not a shared
|
|
75
|
-
* boolean) so a late onLoad from a previous slide can never reveal the
|
|
76
|
-
* current image prematurely; slide changes need no reset effect. */
|
|
77
|
-
const [loadedImageSrc, setLoadedImageSrc] = useState<string | null>(null);
|
|
78
44
|
const carouselRef = useRef<HTMLDivElement>(null);
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// `currentItem` lookup stay in sync.
|
|
45
|
+
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
|
|
47
|
+
// Clamp `currentIndex` whenever `media` shrinks (e.g. an admin removes
|
|
48
|
+
// a slide while the user is on the last one) so the thumbnail active
|
|
49
|
+
// state + `currentItem` lookup stay in sync. Without this, the
|
|
50
|
+
// `media[currentIndex] || media[0]` fallback renders the first slide
|
|
51
|
+
// but every thumbnail's `isActive = index === currentIndex` reads
|
|
52
|
+
// false — visually nothing is selected.
|
|
88
53
|
useEffect(() => {
|
|
89
54
|
if (currentIndex >= media.length && media.length > 0) {
|
|
90
55
|
setCurrentIndex(media.length - 1);
|
|
91
56
|
}
|
|
92
57
|
}, [media.length, currentIndex]);
|
|
93
58
|
|
|
59
|
+
// Early return if no media provided
|
|
60
|
+
if (!media || media.length === 0) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
94
64
|
const currentItem = media[currentIndex] || media[0];
|
|
95
65
|
|
|
96
|
-
//
|
|
66
|
+
// Additional safety check
|
|
67
|
+
if (!currentItem) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Navigation functions — `<Video>` (MuxPlayer/YT facade) owns its own
|
|
72
|
+
// play/pause lifecycle, so the previous bare-`<video>`-element pause
|
|
73
|
+
// logic in nextSlide/prevSlide/selectSlide is no longer needed.
|
|
97
74
|
const nextSlide = useCallback(() => {
|
|
98
75
|
setCurrentIndex((prev) => (prev + 1) % media.length);
|
|
99
76
|
}, [media.length]);
|
|
@@ -103,13 +80,14 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
103
80
|
}, [media.length]);
|
|
104
81
|
|
|
105
82
|
const selectSlide = useCallback((index: number) => {
|
|
106
|
-
|
|
107
|
-
|
|
83
|
+
if (index === currentIndex) return;
|
|
84
|
+
setCurrentIndex(index);
|
|
85
|
+
}, [currentIndex]);
|
|
108
86
|
|
|
109
87
|
// Keyboard navigation - only when carousel is focused
|
|
110
|
-
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
88
|
+
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
111
89
|
if (media.length <= 1) return;
|
|
112
|
-
|
|
90
|
+
|
|
113
91
|
if (e.key === 'ArrowLeft') {
|
|
114
92
|
e.preventDefault();
|
|
115
93
|
prevSlide();
|
|
@@ -119,33 +97,21 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
119
97
|
}
|
|
120
98
|
}, [nextSlide, prevSlide, media.length]);
|
|
121
99
|
|
|
122
|
-
// Early return if no media provided
|
|
123
|
-
if (!media || media.length === 0) {
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Additional safety check
|
|
128
|
-
if (!currentItem) {
|
|
129
|
-
return null;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
100
|
// Touch/swipe handling
|
|
133
101
|
const minSwipeDistance = 50;
|
|
134
102
|
|
|
135
|
-
const onTouchStart = (e: TouchEvent) => {
|
|
103
|
+
const onTouchStart = (e: React.TouchEvent) => {
|
|
136
104
|
setTouchEnd(null);
|
|
137
105
|
setTouchStart(e.targetTouches[0].clientX);
|
|
138
106
|
};
|
|
139
107
|
|
|
140
|
-
const onTouchMove = (e: TouchEvent) => {
|
|
108
|
+
const onTouchMove = (e: React.TouchEvent) => {
|
|
141
109
|
setTouchEnd(e.targetTouches[0].clientX);
|
|
142
110
|
};
|
|
143
111
|
|
|
144
112
|
const onTouchEnd = () => {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (touchStart === null || touchEnd === null) return;
|
|
148
|
-
|
|
113
|
+
if (!touchStart || !touchEnd) return;
|
|
114
|
+
|
|
149
115
|
const distance = touchStart - touchEnd;
|
|
150
116
|
const isLeftSwipe = distance > minSwipeDistance;
|
|
151
117
|
const isRightSwipe = distance < -minSwipeDistance;
|
|
@@ -158,92 +124,52 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
158
124
|
}
|
|
159
125
|
};
|
|
160
126
|
|
|
161
|
-
|
|
162
|
-
|
|
127
|
+
// Render YouTube embed via the SSoT `<Video>` — same lite-youtube
|
|
128
|
+
// facade everywhere, no carousel-local fork.
|
|
129
|
+
const renderYouTubeEmbed = (item: MediaItem, index: number) => (
|
|
130
|
+
<Video
|
|
131
|
+
kind="youtube"
|
|
132
|
+
url={item.src}
|
|
133
|
+
title={item.alt || `Video ${index + 1}`}
|
|
134
|
+
layout="fill"
|
|
135
|
+
priority={index === currentIndex}
|
|
136
|
+
/>
|
|
137
|
+
);
|
|
163
138
|
|
|
164
|
-
// Render
|
|
165
|
-
//
|
|
166
|
-
//
|
|
139
|
+
// Render video via the SSoT `<Video>` — MuxPlayer handles both HLS
|
|
140
|
+
// and plain MP4, so the carousel no longer needs its own bare
|
|
141
|
+
// `<video>` element + custom play overlay + 60 LOC of error handling.
|
|
167
142
|
const renderVideo = (item: MediaItem, index: number) => (
|
|
168
143
|
<Video
|
|
169
|
-
key={`video-${index}-${item.src}`}
|
|
170
144
|
url={item.src}
|
|
171
|
-
poster={item.poster
|
|
172
|
-
title={item.alt || `Video ${index + 1}`}
|
|
145
|
+
poster={item.poster}
|
|
173
146
|
muted
|
|
174
147
|
layout="fill"
|
|
175
|
-
priority={
|
|
176
|
-
className="w-full h-full bg-black"
|
|
148
|
+
priority={index === currentIndex}
|
|
177
149
|
/>
|
|
178
150
|
);
|
|
179
151
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
</Link>
|
|
152
|
+
// Render image
|
|
153
|
+
const renderImage = (item: MediaItem, index: number) => (
|
|
154
|
+
<div className="absolute inset-0 bg-black">
|
|
155
|
+
<img
|
|
156
|
+
src={item.src}
|
|
157
|
+
alt={item.alt || `Media ${index + 1}`}
|
|
158
|
+
className={`w-full h-full object-${objectFit}`}
|
|
159
|
+
loading="lazy"
|
|
160
|
+
onError={(e) => {
|
|
161
|
+
const target = e.target as HTMLImageElement;
|
|
162
|
+
target.style.display = 'none';
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
194
165
|
</div>
|
|
195
166
|
);
|
|
196
167
|
|
|
197
|
-
// Render
|
|
198
|
-
// avoid the browser broken-image icon flash.
|
|
199
|
-
const renderImage = (item: MediaItem, index: number) => {
|
|
200
|
-
const rawSrc = typeof item.src === 'string' ? item.src.trim() : '';
|
|
201
|
-
if (!rawSrc) {
|
|
202
|
-
return (
|
|
203
|
-
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-ods-card px-6 text-center">
|
|
204
|
-
<p className="text-ods-text-secondary text-sm max-w-md">No image URL for this slide.</p>
|
|
205
|
-
</div>
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
if (brokenResourceUrls[rawSrc]) {
|
|
209
|
-
return renderImageUnavailable(rawSrc);
|
|
210
|
-
}
|
|
211
|
-
const mainImagePriority = Boolean(posterPriority && index === 0);
|
|
212
|
-
const imageLoaded = loadedImageSrc === rawSrc;
|
|
213
|
-
return (
|
|
214
|
-
<div className="absolute inset-0 bg-ods-bg">
|
|
215
|
-
{!imageLoaded && (
|
|
216
|
-
<div
|
|
217
|
-
className="absolute inset-0 z-[1] bg-ods-card/90 animate-pulse"
|
|
218
|
-
aria-hidden
|
|
219
|
-
/>
|
|
220
|
-
)}
|
|
221
|
-
<Image
|
|
222
|
-
src={displayImageSrc(rawSrc)}
|
|
223
|
-
alt={item.alt || `Media ${index + 1}`}
|
|
224
|
-
className={cn(
|
|
225
|
-
`w-full h-full object-${objectFit} relative z-[2]`,
|
|
226
|
-
!imageLoaded && 'opacity-0',
|
|
227
|
-
imageLoaded && 'opacity-100 transition-opacity duration-200'
|
|
228
|
-
)}
|
|
229
|
-
priority={mainImagePriority}
|
|
230
|
-
loading={mainImagePriority ? 'eager' : 'lazy'}
|
|
231
|
-
sizes="(max-width: 1279px) 100vw, 1428px"
|
|
232
|
-
width={1428}
|
|
233
|
-
height={802}
|
|
234
|
-
unoptimized
|
|
235
|
-
onLoad={() => setLoadedImageSrc(rawSrc)}
|
|
236
|
-
onError={() => onImageError(rawSrc)}
|
|
237
|
-
/>
|
|
238
|
-
</div>
|
|
239
|
-
);
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// Render main media item — video AND youtube both route through the SSoT
|
|
243
|
-
// `<Video>` (its `auto` kind owns the detection).
|
|
168
|
+
// Render main media item
|
|
244
169
|
const renderMainMedia = (item: MediaItem, index: number) => {
|
|
245
170
|
switch (item.type) {
|
|
246
171
|
case 'youtube':
|
|
172
|
+
return renderYouTubeEmbed(item, index);
|
|
247
173
|
case 'video':
|
|
248
174
|
return renderVideo(item, index);
|
|
249
175
|
case 'image':
|
|
@@ -252,6 +178,55 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
252
178
|
}
|
|
253
179
|
};
|
|
254
180
|
|
|
181
|
+
// Render thumbnail
|
|
182
|
+
const renderThumbnail = (item: MediaItem, index: number) => {
|
|
183
|
+
const isActive = index === currentIndex;
|
|
184
|
+
|
|
185
|
+
let thumbnailSrc = item.src;
|
|
186
|
+
if (item.type === 'youtube') {
|
|
187
|
+
// Use the SSoT `extractYouTubeId` (strict URL parsing, ReDoS-safe)
|
|
188
|
+
// rather than a local regex so YouTube id extraction has a single
|
|
189
|
+
// implementation across the lib.
|
|
190
|
+
const videoId = extractYouTubeId(item.src);
|
|
191
|
+
thumbnailSrc = videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : item.src;
|
|
192
|
+
} else if (item.type === 'video' && item.poster) {
|
|
193
|
+
thumbnailSrc = item.poster;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<button
|
|
198
|
+
key={index}
|
|
199
|
+
onClick={() => selectSlide(index)}
|
|
200
|
+
className={cn(
|
|
201
|
+
"relative flex-shrink-0 overflow-hidden transition-all duration-200",
|
|
202
|
+
"w-20 h-20 md:w-24 md:h-24 rounded-lg border-2",
|
|
203
|
+
isActive
|
|
204
|
+
? "border-[#FFC008] ring-2 ring-[#FFC008]/20"
|
|
205
|
+
: "border-ods-border hover:border-[#888888]"
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
<img
|
|
209
|
+
src={thumbnailSrc}
|
|
210
|
+
alt={item.alt || `Thumbnail ${index + 1}`}
|
|
211
|
+
className="w-full h-full object-cover"
|
|
212
|
+
loading="lazy"
|
|
213
|
+
/>
|
|
214
|
+
|
|
215
|
+
{/* Play icon overlay for videos */}
|
|
216
|
+
{(item.type === 'video' || item.type === 'youtube') && (
|
|
217
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
|
218
|
+
<VideoPlayBadge size="sm" />
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
{/* Active indicator */}
|
|
223
|
+
{isActive && (
|
|
224
|
+
<div className="absolute bottom-1 right-1 w-2 h-2 bg-[#FFC008] rounded-full" />
|
|
225
|
+
)}
|
|
226
|
+
</button>
|
|
227
|
+
);
|
|
228
|
+
};
|
|
229
|
+
|
|
255
230
|
// Get CSS for aspect ratio
|
|
256
231
|
const getAspectRatioClass = () => {
|
|
257
232
|
switch (aspectRatio) {
|
|
@@ -268,14 +243,12 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
268
243
|
};
|
|
269
244
|
|
|
270
245
|
return (
|
|
271
|
-
<div className={cn("
|
|
272
|
-
{/* Main Display Area with Fixed Aspect Ratio
|
|
273
|
-
|
|
274
|
-
<div
|
|
246
|
+
<div className={cn("space-y-4", className)}>
|
|
247
|
+
{/* Main Display Area with Fixed Aspect Ratio */}
|
|
248
|
+
<div
|
|
275
249
|
ref={carouselRef}
|
|
276
250
|
className={cn(
|
|
277
|
-
"relative bg-ods-bg border border-ods-border rounded-
|
|
278
|
-
"[box-shadow:var(--shadow-media-frame)]",
|
|
251
|
+
"relative bg-ods-bg border border-ods-border rounded-2xl overflow-hidden group w-full",
|
|
279
252
|
getAspectRatioClass()
|
|
280
253
|
)}
|
|
281
254
|
onTouchStart={onTouchStart}
|
|
@@ -289,64 +262,45 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
289
262
|
{/* Media content */}
|
|
290
263
|
{renderMainMedia(currentItem, currentIndex)}
|
|
291
264
|
|
|
292
|
-
{/* Navigation Arrows
|
|
293
|
-
|
|
294
|
-
{showArrows && media.length > 1 && (
|
|
265
|
+
{/* Navigation Arrows - only show if multiple items */}
|
|
266
|
+
{media.length > 1 && (
|
|
295
267
|
<>
|
|
296
|
-
<
|
|
297
|
-
variant="transparent"
|
|
268
|
+
<button
|
|
298
269
|
onClick={prevSlide}
|
|
299
|
-
className="absolute left-3 top-1/2
|
|
270
|
+
className="absolute left-3 top-1/2 transform -translate-y-1/2 bg-black/50 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-black/70 z-10"
|
|
300
271
|
aria-label="Previous media"
|
|
301
272
|
>
|
|
302
273
|
<ChevronLeftIcon />
|
|
303
|
-
</
|
|
274
|
+
</button>
|
|
304
275
|
|
|
305
|
-
<
|
|
306
|
-
variant="transparent"
|
|
276
|
+
<button
|
|
307
277
|
onClick={nextSlide}
|
|
308
|
-
className="absolute right-3 top-1/2
|
|
278
|
+
className="absolute right-3 top-1/2 transform -translate-y-1/2 bg-black/50 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 hover:bg-black/70 z-10"
|
|
309
279
|
aria-label="Next media"
|
|
310
280
|
>
|
|
311
281
|
<ChevronRightIcon />
|
|
312
|
-
</
|
|
282
|
+
</button>
|
|
283
|
+
|
|
284
|
+
{/* Media Counter */}
|
|
285
|
+
<div className="absolute top-3 right-3 bg-black/70 text-white px-3 py-1 rounded-lg text-sm font-medium">
|
|
286
|
+
{currentIndex + 1} / {media.length}
|
|
287
|
+
</div>
|
|
313
288
|
</>
|
|
314
289
|
)}
|
|
315
290
|
</div>
|
|
316
291
|
|
|
317
|
-
{/*
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
variant="transparent"
|
|
328
|
-
aria-current={isActive ? 'true' : undefined}
|
|
329
|
-
aria-label={`Go to slide ${index + 1}`}
|
|
330
|
-
onClick={() => selectSlide(index)}
|
|
331
|
-
className="flex h-6 w-6 md:h-6 shrink-0 items-center justify-center rounded-full p-0"
|
|
332
|
-
>
|
|
333
|
-
<span
|
|
334
|
-
className={cn(
|
|
335
|
-
"size-2 rounded-full transition-colors duration-150",
|
|
336
|
-
isActive
|
|
337
|
-
? "bg-[var(--ods-flamingo-pink-base)]"
|
|
338
|
-
: "bg-[var(--ods-system-greys-soft-grey)] hover:bg-[var(--ods-system-greys-soft-grey-hover)]"
|
|
339
|
-
)}
|
|
340
|
-
/>
|
|
341
|
-
</Button>
|
|
342
|
-
);
|
|
343
|
-
})}
|
|
292
|
+
{/* Thumbnail Navigation - only show if multiple items and showThumbnails is true */}
|
|
293
|
+
{media.length > 1 && showThumbnails && (
|
|
294
|
+
<div className="w-full">
|
|
295
|
+
<div
|
|
296
|
+
ref={thumbnailsRef}
|
|
297
|
+
className="flex gap-2 overflow-x-auto scrollbar-none py-2"
|
|
298
|
+
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
299
|
+
>
|
|
300
|
+
{media.map((item, index) => renderThumbnail(item, index))}
|
|
301
|
+
</div>
|
|
344
302
|
</div>
|
|
345
303
|
)}
|
|
346
304
|
</div>
|
|
347
305
|
);
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
// The carousel's item shape is the lib-wide SSOT — re-exported here so hub
|
|
351
|
-
// code can `import type { MediaItem } from '.../components'`.
|
|
352
|
-
export type { MediaItem };
|
|
306
|
+
});
|
|
@@ -7,18 +7,11 @@ import { cn } from "../../utils/cn"
|
|
|
7
7
|
|
|
8
8
|
const tagVariants = cva(
|
|
9
9
|
[
|
|
10
|
-
"inline-flex items-center justify-center rounded-md",
|
|
10
|
+
"text-h5 h-8 inline-flex items-center justify-center gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-xsf)] rounded-md",
|
|
11
11
|
"transition-colors duration-150",
|
|
12
12
|
],
|
|
13
13
|
{
|
|
14
14
|
variants: {
|
|
15
|
-
// Chip scale. `default` is the classic 32px mono-uppercase tag; `large`
|
|
16
|
-
// is the Figma "Feature Item" chip (48px, h3 bold body label) used by
|
|
17
|
-
// chip groups acting as tabs (OpenFrame categories).
|
|
18
|
-
size: {
|
|
19
|
-
default: "text-h5 h-8 gap-[var(--spacing-system-xxs)] p-[var(--spacing-system-xsf)]",
|
|
20
|
-
large: "text-h3 font-bold h-12 gap-[var(--spacing-system-xs)] p-[var(--spacing-system-s)]",
|
|
21
|
-
},
|
|
22
15
|
variant: {
|
|
23
16
|
primary: [
|
|
24
17
|
"bg-[var(--ods-open-yellow-base)] text-[var(--ods-system-greys-black)]",
|
|
@@ -49,15 +42,6 @@ const tagVariants = cva(
|
|
|
49
42
|
"bg-[var(--ods-system-greys-soft-grey)] text-[var(--ods-system-greys-grey)]",
|
|
50
43
|
"hover:bg-[var(--ods-system-greys-soft-grey-hover)] active:bg-[var(--ods-system-greys-soft-grey-action)]",
|
|
51
44
|
],
|
|
52
|
-
// Active/selected chip state (Figma "Feature Item" active): pink
|
|
53
|
-
// border + pink-secondary fill. A dedicated variant (not appended
|
|
54
|
-
// utilities) so its own hover rules win — the outline variant's
|
|
55
|
-
// hover:bg/hover:border would otherwise repaint an active chip grey.
|
|
56
|
-
selected: [
|
|
57
|
-
"bg-[var(--ods-flamingo-pink-secondary)] text-[var(--ods-system-greys-white)] border border-[var(--ods-flamingo-pink-base)]",
|
|
58
|
-
"hover:bg-[var(--ods-flamingo-pink-secondary-hover)] hover:border-[var(--ods-flamingo-pink-base)]",
|
|
59
|
-
"active:bg-[var(--ods-flamingo-pink-secondary-action)]",
|
|
60
|
-
],
|
|
61
45
|
// Matches the EntityTagBadges / StatusBadge tag skin (ods-card + ods-border,
|
|
62
46
|
// mono uppercase) so the tag-editor chips render identically to the public
|
|
63
47
|
// tag badges. Used for FilterChipData variant 'tag' (see search-input).
|
|
@@ -69,7 +53,6 @@ const tagVariants = cva(
|
|
|
69
53
|
},
|
|
70
54
|
defaultVariants: {
|
|
71
55
|
variant: "primary",
|
|
72
|
-
size: "default",
|
|
73
56
|
},
|
|
74
57
|
}
|
|
75
58
|
)
|
|
@@ -103,7 +86,6 @@ export interface TagProps
|
|
|
103
86
|
function Tag({
|
|
104
87
|
label,
|
|
105
88
|
variant,
|
|
106
|
-
size,
|
|
107
89
|
icon,
|
|
108
90
|
onClose,
|
|
109
91
|
className,
|
|
@@ -115,7 +97,7 @@ function Tag({
|
|
|
115
97
|
return (
|
|
116
98
|
<Comp
|
|
117
99
|
className={cn(
|
|
118
|
-
tagVariants({ variant
|
|
100
|
+
tagVariants({ variant }),
|
|
119
101
|
disabled && disabledTagClasses,
|
|
120
102
|
className
|
|
121
103
|
)}
|
|
@@ -123,7 +105,7 @@ function Tag({
|
|
|
123
105
|
{...props}
|
|
124
106
|
>
|
|
125
107
|
{icon && (
|
|
126
|
-
<span className=
|
|
108
|
+
<span className="flex items-center justify-center size-5 shrink-0">
|
|
127
109
|
{icon}
|
|
128
110
|
</span>
|
|
129
111
|
)}
|
|
@@ -59,8 +59,6 @@
|
|
|
59
59
|
--shadow-card-hover: var(--shadow-md);
|
|
60
60
|
--shadow-modal: var(--shadow-xl);
|
|
61
61
|
--shadow-dropdown: var(--shadow-lg);
|
|
62
|
-
/* Figma media-frame elevation (Flamingo website carousels/panels, 4033:90570) */
|
|
63
|
-
--shadow-media-frame: 0px 24px 48px 0px rgba(0, 0, 0, 0.24);
|
|
64
62
|
--shadow-tooltip: var(--shadow-base);
|
|
65
63
|
--shadow-focus: 0 0 0 3px rgba(255, 192, 8, 0.3);
|
|
66
64
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<!-- source-hash: 4cdb3166112b0dff8dcc314964dcbba3 -->
|
|
2
|
+
Utility functions for processing and handling media items in carousel components, providing standardized interfaces for images, videos, and embedded content.
|
|
3
|
+
|
|
4
|
+
## Key Components
|
|
5
|
+
|
|
6
|
+
- **`MediaItem`** - Interface defining the structure for carousel media items with id, type, URL, and optional metadata
|
|
7
|
+
- **`processMediaItems()`** - Transforms raw media data arrays into standardized MediaItem objects with fallback values
|
|
8
|
+
- **`getMediaThumbnail()`** - Retrieves thumbnail URL for media items (currently returns the main URL)
|
|
9
|
+
|
|
10
|
+
## Usage Example
|
|
11
|
+
|
|
12
|
+
```typescript
|
|
13
|
+
import { MediaItem, processMediaItems, getMediaThumbnail } from './media-carousel-utils';
|
|
14
|
+
|
|
15
|
+
// Process raw media data
|
|
16
|
+
const rawItems = [
|
|
17
|
+
{ id: '1', type: 'image', url: '/photo1.jpg', alt: 'Photo 1' },
|
|
18
|
+
{ url: '/video1.mp4', type: 'video', caption: 'Demo video' },
|
|
19
|
+
{ url: '/embed-content' } // Missing fields will use defaults
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const mediaItems: MediaItem[] = processMediaItems(rawItems);
|
|
23
|
+
|
|
24
|
+
// Get thumbnail for display
|
|
25
|
+
mediaItems.forEach(item => {
|
|
26
|
+
const thumbnail = getMediaThumbnail(item);
|
|
27
|
+
console.log(`${item.type}: ${thumbnail}`);
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The utility handles missing properties gracefully, using index-based IDs and 'image' as the default type when not specified.
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
export interface MediaItem {
|
|
2
|
-
|
|
3
|
-
* omit this (hub vendor/profile/category converters all do). */
|
|
4
|
-
id?: string;
|
|
2
|
+
id: string;
|
|
5
3
|
type: 'image' | 'video' | 'youtube';
|
|
6
4
|
src: string;
|
|
7
5
|
alt?: string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Media carousel utilities stub
|
|
2
|
+
export interface MediaItem {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'image' | 'video' | 'embed';
|
|
5
|
+
url: string;
|
|
6
|
+
alt?: string;
|
|
7
|
+
caption?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function processMediaItems(items: any[]): MediaItem[] {
|
|
11
|
+
return items.map((item, index) => ({
|
|
12
|
+
id: item.id || index.toString(),
|
|
13
|
+
type: item.type || 'image',
|
|
14
|
+
url: item.url || '',
|
|
15
|
+
alt: item.alt || '',
|
|
16
|
+
caption: item.caption || ''
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMediaThumbnail(item: MediaItem): string {
|
|
21
|
+
return item.url;
|
|
22
|
+
}
|