@flamingo-stack/openframe-frontend-core 0.0.407 → 0.0.408
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-4UZH3MT2.js → chunk-5HOII3WB.js} +2 -2
- package/dist/{chunk-SY3QS2E5.cjs → chunk-A3RW7M6B.cjs} +26 -26
- package/dist/{chunk-SY3QS2E5.cjs.map → chunk-A3RW7M6B.cjs.map} +1 -1
- package/dist/{chunk-43H2WOAW.js → chunk-E4J5KKHH.js} +2 -2
- package/dist/{chunk-LPC3C7MG.cjs → chunk-F4KE5AZO.cjs} +37 -37
- package/dist/{chunk-LPC3C7MG.cjs.map → chunk-F4KE5AZO.cjs.map} +1 -1
- package/dist/{chunk-NSF7JUXF.js → chunk-IK5AR6QG.js} +4 -4
- package/dist/{chunk-CVYSW33D.js → chunk-IVM47EGI.js} +149 -109
- package/dist/chunk-IVM47EGI.js.map +1 -0
- package/dist/{chunk-KLBEIJVZ.js → chunk-J6M2G6TD.js} +4 -4
- package/dist/{chunk-R4MXLJUS.cjs → chunk-KPGKNUJF.cjs} +31 -31
- package/dist/{chunk-R4MXLJUS.cjs.map → chunk-KPGKNUJF.cjs.map} +1 -1
- package/dist/{chunk-74FWQI73.cjs → chunk-KRLBVBYU.cjs} +11 -11
- package/dist/{chunk-74FWQI73.cjs.map → chunk-KRLBVBYU.cjs.map} +1 -1
- package/dist/{chunk-HB63TQQO.js → chunk-KZPVN7AZ.js} +2 -2
- package/dist/{chunk-XFP43YKG.js → chunk-L6OPVKVR.js} +4 -4
- package/dist/{chunk-H66L2265.js → chunk-LQWYXIKB.js} +4 -4
- package/dist/{chunk-DOBNDDNE.cjs → chunk-NEMUCNCG.cjs} +59 -59
- package/dist/{chunk-DOBNDDNE.cjs.map → chunk-NEMUCNCG.cjs.map} +1 -1
- package/dist/{chunk-GJSLKATJ.js → chunk-O5W26SCL.js} +2 -2
- package/dist/{chunk-5M5W6XDY.cjs → chunk-RL344I7V.cjs} +7 -7
- package/dist/{chunk-5M5W6XDY.cjs.map → chunk-RL344I7V.cjs.map} +1 -1
- package/dist/{chunk-Q2MC3CBI.cjs → chunk-RPGFIUN3.cjs} +26 -26
- package/dist/{chunk-Q2MC3CBI.cjs.map → chunk-RPGFIUN3.cjs.map} +1 -1
- package/dist/{chunk-GGDM5J4B.cjs → chunk-SOALFYU6.cjs} +149 -109
- package/dist/chunk-SOALFYU6.cjs.map +1 -0
- package/dist/{chunk-PRKMOF2U.cjs → chunk-T6ZUBXPA.cjs} +9 -9
- package/dist/{chunk-PRKMOF2U.cjs.map → chunk-T6ZUBXPA.cjs.map} +1 -1
- package/dist/{chunk-WCPUJA3Y.cjs → chunk-V3EZQWHQ.cjs} +5 -5
- package/dist/{chunk-WCPUJA3Y.cjs.map → chunk-V3EZQWHQ.cjs.map} +1 -1
- package/dist/{chunk-HJIRG7R6.js → chunk-VUW4G6SU.js} +2 -2
- package/dist/{chunk-YX7C5JD7.cjs → chunk-WGOATV3P.cjs} +62 -62
- package/dist/{chunk-YX7C5JD7.cjs.map → chunk-WGOATV3P.cjs.map} +1 -1
- package/dist/{chunk-LC6RRLFA.cjs → chunk-YRKPMYI3.cjs} +15 -15
- package/dist/{chunk-LC6RRLFA.cjs.map → chunk-YRKPMYI3.cjs.map} +1 -1
- package/dist/{chunk-7XVFSGQA.js → chunk-ZF5O3KUQ.js} +2 -2
- package/dist/{chunk-GMZL4T7C.js → chunk-ZMFQWMM7.js} +2 -2
- package/dist/components/case-studies/index.cjs +9 -8
- package/dist/components/case-studies/index.cjs.map +1 -1
- package/dist/components/case-studies/index.js +3 -2
- package/dist/components/case-studies/index.js.map +1 -1
- package/dist/components/chat/index.cjs +3 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +2 -1
- package/dist/components/chat/quick-action-chip.d.ts +11 -2
- package/dist/components/chat/quick-action-chip.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +4 -3
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +3 -2
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -3
- package/dist/components/embeds/index.cjs.map +1 -1
- package/dist/components/embeds/index.js +3 -2
- package/dist/components/embeds/reddit-embed-client.d.ts.map +1 -1
- package/dist/components/faq/index.cjs +4 -3
- package/dist/components/faq/index.cjs.map +1 -1
- package/dist/components/faq/index.js +3 -2
- package/dist/components/features/index.cjs +3 -2
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +2 -1
- 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 +1 -0
- 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 +28 -4
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +3 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +2 -1
- 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 +4 -3
- package/dist/components/related-content/index.cjs.map +1 -1
- package/dist/components/related-content/index.js +3 -2
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +2 -1
- package/dist/components/ui/tag.d.ts +3 -2
- package/dist/components/ui/tag.d.ts.map +1 -1
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +2 -1
- package/dist/utils/media-carousel-utils-stub.d.ts +3 -1
- package/dist/utils/media-carousel-utils-stub.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/chat/quick-action-chip.tsx +18 -4
- package/src/components/embeds/reddit-embed-client.tsx +0 -3
- package/src/components/index.ts +5 -0
- package/src/components/media-carousel.tsx +195 -149
- package/src/components/ui/tag.tsx +21 -3
- package/src/styles/ods-design-tokens.css +2 -0
- package/src/utils/media-carousel-utils-stub.ts +3 -1
- package/dist/chunk-CVYSW33D.js.map +0 -1
- package/dist/chunk-GGDM5J4B.cjs.map +0 -1
- package/dist/utils/media-carousel-utils.d.ts +0 -10
- package/dist/utils/media-carousel-utils.d.ts.map +0 -1
- package/src/utils/.media-carousel-utils.md +0 -31
- package/src/utils/media-carousel-utils.ts +0 -22
- /package/dist/{chunk-4UZH3MT2.js.map → chunk-5HOII3WB.js.map} +0 -0
- /package/dist/{chunk-43H2WOAW.js.map → chunk-E4J5KKHH.js.map} +0 -0
- /package/dist/{chunk-NSF7JUXF.js.map → chunk-IK5AR6QG.js.map} +0 -0
- /package/dist/{chunk-KLBEIJVZ.js.map → chunk-J6M2G6TD.js.map} +0 -0
- /package/dist/{chunk-HB63TQQO.js.map → chunk-KZPVN7AZ.js.map} +0 -0
- /package/dist/{chunk-XFP43YKG.js.map → chunk-L6OPVKVR.js.map} +0 -0
- /package/dist/{chunk-H66L2265.js.map → chunk-LQWYXIKB.js.map} +0 -0
- /package/dist/{chunk-GJSLKATJ.js.map → chunk-O5W26SCL.js.map} +0 -0
- /package/dist/{chunk-HJIRG7R6.js.map → chunk-VUW4G6SU.js.map} +0 -0
- /package/dist/{chunk-7XVFSGQA.js.map → chunk-ZF5O3KUQ.js.map} +0 -0
- /package/dist/{chunk-GMZL4T7C.js.map → chunk-ZMFQWMM7.js.map} +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useRef, useEffect, memo, useCallback } from 'react';
|
|
3
|
+
import { useState, useRef, useEffect, memo, useCallback, type KeyboardEvent, type TouchEvent } from 'react';
|
|
4
4
|
import { cn } from "../utils/cn";
|
|
5
5
|
import { MediaItem } from '../utils/media-carousel-utils-stub';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { Image, Link } from '../embed-shims';
|
|
7
|
+
import { Button } from './ui/button';
|
|
8
|
+
import { Video } from './features/video';
|
|
9
|
+
import { ImageOffIcon } from './icons-v2-generated/audio-and-visual/image-off-icon';
|
|
8
10
|
|
|
9
11
|
// Navigation icons
|
|
10
12
|
const ChevronLeftIcon = () => (
|
|
@@ -19,58 +21,79 @@ const ChevronRightIcon = () => (
|
|
|
19
21
|
</svg>
|
|
20
22
|
);
|
|
21
23
|
|
|
22
|
-
|
|
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 {
|
|
23
38
|
media: MediaItem[];
|
|
24
39
|
className?: string;
|
|
25
40
|
/** Use aspect ratio instead of height classes for stable dimensions */
|
|
26
41
|
aspectRatio?: '16/9' | '4/3' | '3/2' | '1/1';
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
/** Hover-revealed prev/next arrows (default true; dots always render). */
|
|
43
|
+
showArrows?: boolean;
|
|
29
44
|
/** How content should fit within the container */
|
|
30
45
|
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;
|
|
31
57
|
}
|
|
32
58
|
|
|
33
|
-
export const MediaCarousel = memo(function MediaCarousel({
|
|
34
|
-
media,
|
|
59
|
+
export const MediaCarousel = memo(function MediaCarousel({
|
|
60
|
+
media,
|
|
35
61
|
className,
|
|
36
62
|
aspectRatio = "16/9",
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
showArrows = true,
|
|
64
|
+
objectFit = 'contain',
|
|
65
|
+
posterPriority = false,
|
|
66
|
+
transformImageSrc,
|
|
67
|
+
reserveDotRow = false,
|
|
40
68
|
}: MediaCarouselProps) {
|
|
41
69
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
42
70
|
const [touchStart, setTouchStart] = useState<number | null>(null);
|
|
43
71
|
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);
|
|
44
78
|
const carouselRef = useRef<HTMLDivElement>(null);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
//
|
|
79
|
+
|
|
80
|
+
const onImageError = useCallback((originalUrl: string) => {
|
|
81
|
+
const key = originalUrl.trim() || '__missing_src__';
|
|
82
|
+
setBrokenResourceUrls((b) => (b[key] ? b : { ...b, [key]: true }));
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
// Clamp `currentIndex` whenever `media` shrinks (e.g. an admin removes a
|
|
86
|
+
// slide while the user is on the last one) so the dot active state +
|
|
87
|
+
// `currentItem` lookup stay in sync.
|
|
53
88
|
useEffect(() => {
|
|
54
89
|
if (currentIndex >= media.length && media.length > 0) {
|
|
55
90
|
setCurrentIndex(media.length - 1);
|
|
56
91
|
}
|
|
57
92
|
}, [media.length, currentIndex]);
|
|
58
93
|
|
|
59
|
-
// Early return if no media provided
|
|
60
|
-
if (!media || media.length === 0) {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
94
|
const currentItem = media[currentIndex] || media[0];
|
|
65
95
|
|
|
66
|
-
//
|
|
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.
|
|
96
|
+
// Navigation — `<Video>` owns its own play/pause lifecycle.
|
|
74
97
|
const nextSlide = useCallback(() => {
|
|
75
98
|
setCurrentIndex((prev) => (prev + 1) % media.length);
|
|
76
99
|
}, [media.length]);
|
|
@@ -80,14 +103,13 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
80
103
|
}, [media.length]);
|
|
81
104
|
|
|
82
105
|
const selectSlide = useCallback((index: number) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}, [currentIndex]);
|
|
106
|
+
setCurrentIndex((prev) => (index === prev ? prev : index));
|
|
107
|
+
}, []);
|
|
86
108
|
|
|
87
109
|
// Keyboard navigation - only when carousel is focused
|
|
88
|
-
const handleKeyDown = useCallback((e:
|
|
110
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
89
111
|
if (media.length <= 1) return;
|
|
90
|
-
|
|
112
|
+
|
|
91
113
|
if (e.key === 'ArrowLeft') {
|
|
92
114
|
e.preventDefault();
|
|
93
115
|
prevSlide();
|
|
@@ -97,21 +119,33 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
97
119
|
}
|
|
98
120
|
}, [nextSlide, prevSlide, media.length]);
|
|
99
121
|
|
|
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
|
+
|
|
100
132
|
// Touch/swipe handling
|
|
101
133
|
const minSwipeDistance = 50;
|
|
102
134
|
|
|
103
|
-
const onTouchStart = (e:
|
|
135
|
+
const onTouchStart = (e: TouchEvent) => {
|
|
104
136
|
setTouchEnd(null);
|
|
105
137
|
setTouchStart(e.targetTouches[0].clientX);
|
|
106
138
|
};
|
|
107
139
|
|
|
108
|
-
const onTouchMove = (e:
|
|
140
|
+
const onTouchMove = (e: TouchEvent) => {
|
|
109
141
|
setTouchEnd(e.targetTouches[0].clientX);
|
|
110
142
|
};
|
|
111
143
|
|
|
112
144
|
const onTouchEnd = () => {
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
// Explicit null checks — a swipe starting/ending at the viewport edge has
|
|
146
|
+
// clientX === 0, which a truthiness check would wrongly discard.
|
|
147
|
+
if (touchStart === null || touchEnd === null) return;
|
|
148
|
+
|
|
115
149
|
const distance = touchStart - touchEnd;
|
|
116
150
|
const isLeftSwipe = distance > minSwipeDistance;
|
|
117
151
|
const isRightSwipe = distance < -minSwipeDistance;
|
|
@@ -124,52 +158,92 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
124
158
|
}
|
|
125
159
|
};
|
|
126
160
|
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
);
|
|
161
|
+
const displayImageSrc = (url: string): string =>
|
|
162
|
+
transformImageSrc ? transformImageSrc(url) : url;
|
|
138
163
|
|
|
139
|
-
// Render
|
|
140
|
-
//
|
|
141
|
-
//
|
|
164
|
+
// Render any playable slide via the SSoT `<Video>` — its default `auto`
|
|
165
|
+
// routing owns the youtube-vs-file decision (lite-youtube facade vs
|
|
166
|
+
// MuxPlayer), so the carousel keeps NO url-detection fork of its own.
|
|
142
167
|
const renderVideo = (item: MediaItem, index: number) => (
|
|
143
168
|
<Video
|
|
169
|
+
key={`video-${index}-${item.src}`}
|
|
144
170
|
url={item.src}
|
|
145
|
-
poster={item.poster}
|
|
171
|
+
poster={item.poster ? displayImageSrc(item.poster) : undefined}
|
|
172
|
+
title={item.alt || `Video ${index + 1}`}
|
|
146
173
|
muted
|
|
147
174
|
layout="fill"
|
|
148
|
-
priority={index ===
|
|
175
|
+
priority={Boolean(posterPriority && index === 0)}
|
|
176
|
+
className="w-full h-full bg-black"
|
|
149
177
|
/>
|
|
150
178
|
);
|
|
151
179
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
const renderImageUnavailable = (failedUrl: string) => (
|
|
181
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-ods-card px-6 text-center">
|
|
182
|
+
<ImageOffIcon className="size-8 text-ods-text-secondary" />
|
|
183
|
+
<p className="text-ods-text-secondary text-sm max-w-md">
|
|
184
|
+
This image is no longer available at the original URL.
|
|
185
|
+
</p>
|
|
186
|
+
<Link
|
|
187
|
+
href={failedUrl}
|
|
188
|
+
target="_blank"
|
|
189
|
+
rel="noopener noreferrer"
|
|
190
|
+
className="text-ods-accent text-sm underline break-all"
|
|
191
|
+
>
|
|
192
|
+
Open link
|
|
193
|
+
</Link>
|
|
165
194
|
</div>
|
|
166
195
|
);
|
|
167
196
|
|
|
168
|
-
// Render
|
|
197
|
+
// Render image — skeleton until onLoad; keep Image opacity-0 until then to
|
|
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).
|
|
169
244
|
const renderMainMedia = (item: MediaItem, index: number) => {
|
|
170
245
|
switch (item.type) {
|
|
171
246
|
case 'youtube':
|
|
172
|
-
return renderYouTubeEmbed(item, index);
|
|
173
247
|
case 'video':
|
|
174
248
|
return renderVideo(item, index);
|
|
175
249
|
case 'image':
|
|
@@ -178,55 +252,6 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
178
252
|
}
|
|
179
253
|
};
|
|
180
254
|
|
|
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
|
-
|
|
230
255
|
// Get CSS for aspect ratio
|
|
231
256
|
const getAspectRatioClass = () => {
|
|
232
257
|
switch (aspectRatio) {
|
|
@@ -243,12 +268,14 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
243
268
|
};
|
|
244
269
|
|
|
245
270
|
return (
|
|
246
|
-
<div className={cn("
|
|
247
|
-
{/* Main Display Area with Fixed Aspect Ratio
|
|
248
|
-
|
|
271
|
+
<div className={cn("flex flex-col items-center gap-[var(--spacing-system-l)]", className)}>
|
|
272
|
+
{/* Main Display Area with Fixed Aspect Ratio — Figma media frame:
|
|
273
|
+
rounded-md, ods border, semantic elevation token. */}
|
|
274
|
+
<div
|
|
249
275
|
ref={carouselRef}
|
|
250
276
|
className={cn(
|
|
251
|
-
"relative bg-ods-bg border border-ods-border rounded-
|
|
277
|
+
"relative bg-ods-bg border border-ods-border rounded-md overflow-hidden group w-full",
|
|
278
|
+
"[box-shadow:var(--shadow-media-frame)]",
|
|
252
279
|
getAspectRatioClass()
|
|
253
280
|
)}
|
|
254
281
|
onTouchStart={onTouchStart}
|
|
@@ -262,45 +289,64 @@ export const MediaCarousel = memo(function MediaCarousel({
|
|
|
262
289
|
{/* Media content */}
|
|
263
290
|
{renderMainMedia(currentItem, currentIndex)}
|
|
264
291
|
|
|
265
|
-
{/* Navigation Arrows - only
|
|
266
|
-
|
|
292
|
+
{/* Navigation Arrows — hover-revealed, only with multiple items.
|
|
293
|
+
The common <Button> (transparent) with overlay positioning. */}
|
|
294
|
+
{showArrows && media.length > 1 && (
|
|
267
295
|
<>
|
|
268
|
-
<
|
|
296
|
+
<Button
|
|
297
|
+
variant="transparent"
|
|
269
298
|
onClick={prevSlide}
|
|
270
|
-
className="absolute left-3 top-1/2
|
|
299
|
+
className="absolute left-3 top-1/2 h-auto md:h-auto -translate-y-1/2 rounded-full bg-black/50 p-2 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 hover:bg-black/70 z-10"
|
|
271
300
|
aria-label="Previous media"
|
|
272
301
|
>
|
|
273
302
|
<ChevronLeftIcon />
|
|
274
|
-
</
|
|
303
|
+
</Button>
|
|
275
304
|
|
|
276
|
-
<
|
|
305
|
+
<Button
|
|
306
|
+
variant="transparent"
|
|
277
307
|
onClick={nextSlide}
|
|
278
|
-
className="absolute right-3 top-1/2
|
|
308
|
+
className="absolute right-3 top-1/2 h-auto md:h-auto -translate-y-1/2 rounded-full bg-black/50 p-2 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100 hover:bg-black/70 z-10"
|
|
279
309
|
aria-label="Next media"
|
|
280
310
|
>
|
|
281
311
|
<ChevronRightIcon />
|
|
282
|
-
</
|
|
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>
|
|
312
|
+
</Button>
|
|
288
313
|
</>
|
|
289
314
|
)}
|
|
290
315
|
</div>
|
|
291
316
|
|
|
292
|
-
{/*
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
317
|
+
{/* Dot pagination (Figma) — 24px hit target, 8px dot, active pink.
|
|
318
|
+
Plain grouped buttons (NOT tablist: no roving tabIndex/arrow-key
|
|
319
|
+
model here — aria-current marks the active slide instead). */}
|
|
320
|
+
{(media.length > 1 || reserveDotRow) && (
|
|
321
|
+
<div className="flex h-6 items-center gap-[var(--spacing-system-xs)]" role="group" aria-label="Choose slide">
|
|
322
|
+
{media.length > 1 && media.map((item, index) => {
|
|
323
|
+
const isActive = index === currentIndex;
|
|
324
|
+
return (
|
|
325
|
+
<Button
|
|
326
|
+
key={index}
|
|
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
|
+
})}
|
|
302
344
|
</div>
|
|
303
345
|
)}
|
|
304
346
|
</div>
|
|
305
347
|
);
|
|
306
|
-
});
|
|
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 };
|
|
@@ -7,11 +7,18 @@ import { cn } from "../../utils/cn"
|
|
|
7
7
|
|
|
8
8
|
const tagVariants = cva(
|
|
9
9
|
[
|
|
10
|
-
"
|
|
10
|
+
"inline-flex items-center justify-center 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
|
+
},
|
|
15
22
|
variant: {
|
|
16
23
|
primary: [
|
|
17
24
|
"bg-[var(--ods-open-yellow-base)] text-[var(--ods-system-greys-black)]",
|
|
@@ -42,6 +49,15 @@ const tagVariants = cva(
|
|
|
42
49
|
"bg-[var(--ods-system-greys-soft-grey)] text-[var(--ods-system-greys-grey)]",
|
|
43
50
|
"hover:bg-[var(--ods-system-greys-soft-grey-hover)] active:bg-[var(--ods-system-greys-soft-grey-action)]",
|
|
44
51
|
],
|
|
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
|
+
],
|
|
45
61
|
// Matches the EntityTagBadges / StatusBadge tag skin (ods-card + ods-border,
|
|
46
62
|
// mono uppercase) so the tag-editor chips render identically to the public
|
|
47
63
|
// tag badges. Used for FilterChipData variant 'tag' (see search-input).
|
|
@@ -53,6 +69,7 @@ const tagVariants = cva(
|
|
|
53
69
|
},
|
|
54
70
|
defaultVariants: {
|
|
55
71
|
variant: "primary",
|
|
72
|
+
size: "default",
|
|
56
73
|
},
|
|
57
74
|
}
|
|
58
75
|
)
|
|
@@ -86,6 +103,7 @@ export interface TagProps
|
|
|
86
103
|
function Tag({
|
|
87
104
|
label,
|
|
88
105
|
variant,
|
|
106
|
+
size,
|
|
89
107
|
icon,
|
|
90
108
|
onClose,
|
|
91
109
|
className,
|
|
@@ -97,7 +115,7 @@ function Tag({
|
|
|
97
115
|
return (
|
|
98
116
|
<Comp
|
|
99
117
|
className={cn(
|
|
100
|
-
tagVariants({ variant }),
|
|
118
|
+
tagVariants({ variant, size }),
|
|
101
119
|
disabled && disabledTagClasses,
|
|
102
120
|
className
|
|
103
121
|
)}
|
|
@@ -105,7 +123,7 @@ function Tag({
|
|
|
105
123
|
{...props}
|
|
106
124
|
>
|
|
107
125
|
{icon && (
|
|
108
|
-
<span className="flex items-center justify-center size-
|
|
126
|
+
<span className={cn("flex items-center justify-center shrink-0", size === 'large' ? 'size-6' : 'size-5')}>
|
|
109
127
|
{icon}
|
|
110
128
|
</span>
|
|
111
129
|
)}
|
|
@@ -59,6 +59,8 @@
|
|
|
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);
|
|
62
64
|
--shadow-tooltip: var(--shadow-base);
|
|
63
65
|
--shadow-focus: 0 0 0 3px rgba(255, 192, 8, 0.3);
|
|
64
66
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface MediaItem {
|
|
2
|
-
|
|
2
|
+
/** Optional stable key. The carousel keys slides by index; converters may
|
|
3
|
+
* omit this (hub vendor/profile/category converters all do). */
|
|
4
|
+
id?: string;
|
|
3
5
|
type: 'image' | 'video' | 'youtube';
|
|
4
6
|
src: string;
|
|
5
7
|
alt?: string;
|