@rxdrag/website-lib-core 0.0.75 → 0.0.77

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rxdrag/website-lib-core",
3
- "version": "0.0.75",
3
+ "version": "0.0.77",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./index.ts"
@@ -24,9 +24,9 @@
24
24
  "@types/react-dom": "^19.1.0",
25
25
  "eslint": "^7.32.0",
26
26
  "typescript": "^5",
27
- "@rxdrag/eslint-config-custom": "0.2.12",
28
27
  "@rxdrag/tsconfig": "0.2.0",
29
- "@rxdrag/slate-preview": "1.2.60"
28
+ "@rxdrag/slate-preview": "1.2.61",
29
+ "@rxdrag/eslint-config-custom": "0.2.12"
30
30
  },
31
31
  "dependencies": {
32
32
  "@iconify/utils": "^3.0.2",
@@ -36,8 +36,8 @@
36
36
  "lodash-es": "^4.17.21",
37
37
  "react": "^19.1.0",
38
38
  "react-dom": "^19.1.0",
39
- "@rxdrag/entify-lib": "0.0.22",
40
- "@rxdrag/rxcms-models": "0.3.93"
39
+ "@rxdrag/entify-lib": "0.0.23",
40
+ "@rxdrag/rxcms-models": "0.3.94"
41
41
  },
42
42
  "peerDependencies": {
43
43
  "astro": "^4.0.0 || ^5.0.0"
@@ -125,7 +125,7 @@ export class Entify implements IEntify {
125
125
  addonFields?: (keyof Product)[]
126
126
  ) {
127
127
  if (!imageSize) {
128
- imageSize = this.imageSizes.productThumbnial;
128
+ imageSize = this.imageSizes?.productThumbnial;
129
129
  }
130
130
  return await queryFeaturedProducts(
131
131
  count,
@@ -137,7 +137,7 @@ export class Entify implements IEntify {
137
137
 
138
138
  public async getLatestPosts(count?: number, coverSize?: ImageSize) {
139
139
  if (!coverSize) {
140
- coverSize = this.imageSizes.postThumbnial;
140
+ coverSize = this.imageSizes?.postThumbnial;
141
141
  }
142
142
  return await queryLatestPosts(count, coverSize, this.envVariables);
143
143
  }
@@ -14,7 +14,7 @@ import {
14
14
  import { newPageMetaOptions } from "./newPageMetaOptions";
15
15
 
16
16
  function creatProductMediaOptions(imagSize?: ImageSize) {
17
- const imageFields = ["thumbnail(width:400, height:320)", "url"];
17
+ const imageFields = ["thumbnail(width:400, height:320)", "url", "original"];
18
18
  if (imagSize?.width && imagSize?.height) {
19
19
  imageFields.push(
20
20
  `resize(width:${imagSize.width}, height:${imagSize.height})`
@@ -36,7 +36,7 @@ function creatProductMediaOptions(imagSize?: ImageSize) {
36
36
  },
37
37
  ],
38
38
  }
39
- ).media(new MediaQueryOptions().description().file(imageFields));
39
+ ).media(new MediaQueryOptions().mediaType().description().file(imageFields));
40
40
  }
41
41
 
42
42
  export function newQueryProductOptions(imagSize?: ImageSize) {
@@ -70,6 +70,7 @@ export function newQueryProductOptions(imagSize?: ImageSize) {
70
70
  MediaFields.description,
71
71
  MediaFields.name,
72
72
  MediaFields.extName,
73
+ MediaFields.mediaType,
73
74
  ]).file(["url"])
74
75
  )
75
76
  )
@@ -9,7 +9,7 @@ import {
9
9
  WebsiteSettings,
10
10
  } from "@rxdrag/rxcms-models";
11
11
  import {
12
- TMedias,
12
+ TMedia,
13
13
  TPost,
14
14
  TPostCategory,
15
15
  TProduct,
@@ -20,20 +20,17 @@ import {
20
20
  import { ListResult } from "@rxdrag/entify-lib";
21
21
 
22
22
 
23
- export function mediasToViewModel(product?: Product): TMedias | undefined {
23
+ export function mediasToViewModel(product?: Product): TMedia[] | undefined {
24
24
  if (!product) {
25
25
  return undefined;
26
26
  }
27
- return {
28
- externalVideoUrl: product?.externalVideoUrl,
29
- medias: product?.mediaPivots?.map((mediaOnProduct) => {
30
- return {
31
- alt: mediaOnProduct.altText,
32
- seqValue: mediaOnProduct.seqValue,
33
- ...mediaOnProduct.media!,
34
- };
35
- }),
36
- };
27
+ return product?.mediaPivots?.map((mediaOnProduct) => {
28
+ return {
29
+ alt: mediaOnProduct.altText,
30
+ seqValue: mediaOnProduct.seqValue,
31
+ ...mediaOnProduct.media!,
32
+ };
33
+ });
37
34
  }
38
35
 
39
36
  export function attachmentsToViewModel(
@@ -99,7 +96,7 @@ export function productToViewModel(product?: Product): TProduct | undefined {
99
96
  meta: product?.meta,
100
97
  category:
101
98
  product?.category && productCategoryToViewModel(product?.category),
102
- cover: medias?.medias?.[0],
99
+ cover: medias?.[0],
103
100
  medias: medias,
104
101
  attachments:
105
102
  product?.attachmentPivots &&
@@ -4,11 +4,6 @@ export type TMedia = Media & {
4
4
  alt?: string;
5
5
  }
6
6
 
7
- export type TMedias = {
8
- externalVideoUrl?: string;
9
- medias?: TMedia[];
10
- };
11
-
12
7
  export type TProductCategory = {
13
8
  id?: string | null;
14
9
  slug?: string;
@@ -27,7 +22,7 @@ export type TProduct = {
27
22
  shortTitle?: string;
28
23
  description?: string;
29
24
  features?: string;
30
- medias?: TMedias;
25
+ medias?: TMedia[];
31
26
  //从头medias中获取第一个不是视频的图片
32
27
  cover?: Media;
33
28
  content?: string;
@@ -0,0 +1,237 @@
1
+ import clsx from "clsx";
2
+ import { useRef, useState, useEffect, useCallback } from "react";
3
+ import { TMedia } from "../../../entify";
4
+ import { MediaType } from "@rxdrag/rxcms-models";
5
+ import { VideoPlayer } from "./VideoPlayer";
6
+
7
+ export type MainMediaProps = {
8
+ value?: TMedia[];
9
+ selectedId?: string | null;
10
+ aspect?: string;
11
+ enableZoom?: boolean;
12
+ className?: string;
13
+ arrowButtonClass?: string;
14
+ arrowIconClass?: string;
15
+ playButtonClass?: string;
16
+ onPrevious: () => void;
17
+ onNext: () => void;
18
+ canPrevious: boolean;
19
+ canNext: boolean;
20
+ };
21
+
22
+ export const MainMedia = ({
23
+ value,
24
+ selectedId,
25
+ aspect,
26
+ enableZoom,
27
+ className,
28
+ arrowButtonClass,
29
+ arrowIconClass,
30
+ playButtonClass,
31
+ onPrevious,
32
+ onNext,
33
+ canPrevious,
34
+ canNext,
35
+ }: MainMediaProps) => {
36
+ const mainAreaRef = useRef<HTMLDivElement>(null);
37
+ const [isZoomed, setIsZoomed] = useState(false);
38
+ const [position, setPosition] = useState({ x: 0, y: 0 });
39
+ const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
40
+ const lastMousePosRef = useRef({ x: 0, y: 0 });
41
+
42
+ const selectedMedia = value?.find((media) => media.id === selectedId);
43
+ const isVideo = selectedMedia?.mediaType === MediaType.video;
44
+
45
+ // Reset zoom state when image changes
46
+ useEffect(() => {
47
+ setIsZoomed(false);
48
+ setPosition({ x: 0, y: 0 });
49
+ if (hoverTimerRef.current) {
50
+ clearTimeout(hoverTimerRef.current);
51
+ }
52
+ }, [selectedId]);
53
+
54
+ // Clean up timer on unmount
55
+ useEffect(() => {
56
+ return () => {
57
+ if (hoverTimerRef.current) {
58
+ clearTimeout(hoverTimerRef.current);
59
+ }
60
+ };
61
+ }, []);
62
+
63
+ const updateZoomPosition = useCallback((clientX: number, clientY: number) => {
64
+ if (mainAreaRef.current) {
65
+ const { left, top, width, height } =
66
+ mainAreaRef.current.getBoundingClientRect();
67
+ const x = clientX - left;
68
+ const y = clientY - top;
69
+
70
+ // Calculate percentage position (0 to 1)
71
+ const ratioX = Math.max(0, Math.min(1, x / width));
72
+ const ratioY = Math.max(0, Math.min(1, y / height));
73
+
74
+ // Formula: Translate = Dimension * (0.5 - Ratio)
75
+ // This shifts the image opposite to mouse movement relative to center
76
+ const newX = width * (0.5 - ratioX);
77
+ const newY = height * (0.5 - ratioY);
78
+
79
+ setPosition({ x: newX, y: newY });
80
+ }
81
+ }, []);
82
+
83
+ const handleMouseEnter = useCallback(
84
+ (e: React.MouseEvent) => {
85
+ if (!enableZoom || isVideo) return;
86
+ lastMousePosRef.current = { x: e.clientX, y: e.clientY };
87
+
88
+ hoverTimerRef.current = setTimeout(() => {
89
+ setIsZoomed(true);
90
+ updateZoomPosition(
91
+ lastMousePosRef.current.x,
92
+ lastMousePosRef.current.y
93
+ );
94
+ }, 200);
95
+ },
96
+ [enableZoom, isVideo, updateZoomPosition]
97
+ );
98
+
99
+ const handleMouseMove = useCallback(
100
+ (e: React.MouseEvent) => {
101
+ if (!enableZoom || isVideo) return;
102
+ lastMousePosRef.current = { x: e.clientX, y: e.clientY };
103
+
104
+ if (isZoomed) {
105
+ updateZoomPosition(e.clientX, e.clientY);
106
+ }
107
+ },
108
+ [enableZoom, isVideo, isZoomed, updateZoomPosition]
109
+ );
110
+
111
+ const handleMouseLeave = useCallback(() => {
112
+ if (hoverTimerRef.current) {
113
+ clearTimeout(hoverTimerRef.current);
114
+ }
115
+ setIsZoomed(false);
116
+ setPosition({ x: 0, y: 0 });
117
+ }, []);
118
+
119
+ return (
120
+ <div
121
+ ref={mainAreaRef}
122
+ className={clsx(
123
+ "relative group overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 z-0",
124
+ aspect,
125
+ className,
126
+ enableZoom && !isVideo && "cursor-crosshair"
127
+ )}
128
+ onMouseEnter={handleMouseEnter}
129
+ onMouseMove={handleMouseMove}
130
+ onMouseLeave={handleMouseLeave}
131
+ >
132
+ {/* Navigation Arrows - Hide when zoomed to avoid misclick */}
133
+ {!isZoomed && (
134
+ <div className="absolute inset-0 flex items-center justify-between p-4 pointer-events-none z-20">
135
+ <button
136
+ onClick={(e) => {
137
+ e.stopPropagation();
138
+ onPrevious();
139
+ }}
140
+ disabled={!canPrevious}
141
+ className={clsx(
142
+ "pointer-events-auto transition-all duration-200 rounded-full p-2",
143
+ "bg-white/80 dark:bg-black/50 backdrop-blur-sm shadow-sm",
144
+ "hover:bg-white dark:hover:bg-black/70 hover:scale-105 active:scale-95",
145
+ "text-gray-800 dark:text-white",
146
+ !canPrevious
147
+ ? "opacity-0 translate-x-[-10px]"
148
+ : "opacity-0 group-hover:opacity-100 translate-x-0",
149
+ arrowButtonClass
150
+ )}
151
+ aria-label="Previous slide"
152
+ >
153
+ <svg
154
+ xmlns="http://www.w3.org/2000/svg"
155
+ className={clsx("h-5 w-5", arrowIconClass)}
156
+ fill="none"
157
+ viewBox="0 0 24 24"
158
+ stroke="currentColor"
159
+ >
160
+ <path
161
+ strokeLinecap="round"
162
+ strokeLinejoin="round"
163
+ strokeWidth={2}
164
+ d="M15 19l-7-7 7-7"
165
+ />
166
+ </svg>
167
+ </button>
168
+
169
+ <button
170
+ onClick={(e) => {
171
+ e.stopPropagation();
172
+ onNext();
173
+ }}
174
+ disabled={!canNext}
175
+ className={clsx(
176
+ "pointer-events-auto transition-all duration-200 rounded-full p-2",
177
+ "bg-white/80 dark:bg-black/50 backdrop-blur-sm shadow-sm",
178
+ "hover:bg-white dark:hover:bg-black/70 hover:scale-105 active:scale-95",
179
+ "text-gray-800 dark:text-white",
180
+ !canNext
181
+ ? "opacity-0 translate-x-[10px]"
182
+ : "opacity-0 group-hover:opacity-100 translate-x-0",
183
+ arrowButtonClass
184
+ )}
185
+ aria-label="Next slide"
186
+ >
187
+ <svg
188
+ xmlns="http://www.w3.org/2000/svg"
189
+ className={clsx("h-5 w-5", arrowIconClass)}
190
+ fill="none"
191
+ viewBox="0 0 24 24"
192
+ stroke="currentColor"
193
+ >
194
+ <path
195
+ strokeLinecap="round"
196
+ strokeLinejoin="round"
197
+ strokeWidth={2}
198
+ d="M9 5l7 7-7 7"
199
+ />
200
+ </svg>
201
+ </button>
202
+ </div>
203
+ )}
204
+
205
+ {value?.map((media) => (
206
+ <div
207
+ key={media.id}
208
+ className={clsx(
209
+ "absolute inset-0 transition-opacity duration-500 ease-in-out",
210
+ media.id === selectedId
211
+ ? "opacity-100 z-10"
212
+ : "opacity-0 z-0 pointer-events-none"
213
+ )}
214
+ >
215
+ {media.mediaType === MediaType.video ? (
216
+ <VideoPlayer media={media} playButtonClass={playButtonClass} />
217
+ ) : (
218
+ <img
219
+ src={media?.file?.resize || media?.file?.url}
220
+ alt={media?.alt}
221
+ className="w-full h-full object-cover object-center origin-center"
222
+ style={{
223
+ transform:
224
+ media.id === selectedId
225
+ ? `translate(${position.x}px, ${position.y}px) scale(${
226
+ isZoomed ? 2 : 1
227
+ })`
228
+ : undefined,
229
+ transition: isZoomed ? "none" : "transform 0.3s ease-out",
230
+ }}
231
+ />
232
+ )}
233
+ </div>
234
+ ))}
235
+ </div>
236
+ );
237
+ };
@@ -0,0 +1,62 @@
1
+ import clsx from "clsx";
2
+ import { TMedia } from "../../../entify";
3
+ import { MediaType } from "@rxdrag/rxcms-models";
4
+ import { VideoPlayer } from "./VideoPlayer";
5
+
6
+ export type ThumbnailProps = {
7
+ media: TMedia;
8
+ isSelected: boolean;
9
+ onClick: () => void;
10
+ aspect?: string;
11
+ className?: string;
12
+ imageClass?: string;
13
+ playButtonClass?: string;
14
+ };
15
+
16
+ export const Thumbnail = ({
17
+ media,
18
+ isSelected,
19
+ onClick,
20
+ aspect,
21
+ className,
22
+ imageClass,
23
+ playButtonClass,
24
+ }: ThumbnailProps) => {
25
+ return (
26
+ <div
27
+ className={clsx(
28
+ "relative cursor-pointer overflow-hidden rounded-lg transition-all duration-200",
29
+ aspect,
30
+ isSelected
31
+ ? "ring-2 ring-primary ring-offset-2 ring-offset-white dark:ring-offset-gray-950"
32
+ : "opacity-70 hover:opacity-100 hover:ring-2 hover:ring-gray-200 dark:hover:ring-gray-700",
33
+ className
34
+ )}
35
+ onClick={onClick}
36
+ >
37
+ {media.mediaType === MediaType.video ? (
38
+ <div
39
+ className={clsx(
40
+ "w-full h-full relative pointer-events-none [&>div]:!aspect-auto [&>div]:!w-full [&>div]:!h-full transition-transform duration-500",
41
+ isSelected ? "scale-110" : "scale-100",
42
+ imageClass
43
+ )}
44
+ >
45
+ <VideoPlayer media={media} size="small" playButtonClass={playButtonClass} />
46
+ </div>
47
+ ) : (
48
+ <img
49
+ src={
50
+ media?.file?.thumbnail || media?.file?.resize || media?.file?.url
51
+ }
52
+ alt={media?.alt}
53
+ className={clsx(
54
+ "w-full h-full object-cover transition-transform duration-500",
55
+ isSelected ? "scale-110" : "scale-100",
56
+ imageClass
57
+ )}
58
+ />
59
+ )}
60
+ </div>
61
+ );
62
+ };
@@ -0,0 +1,114 @@
1
+ import { Media } from "@rxdrag/rxcms-models";
2
+ import React, { useEffect, useRef, useState } from "react";
3
+ import Hls from "hls.js";
4
+ import clsx from "clsx";
5
+
6
+ export function VideoPlayer(props: {
7
+ media: Media;
8
+ size?: "normal" | "small";
9
+ playButtonClass?: string;
10
+ }) {
11
+ const { media, size = "normal", playButtonClass } = props;
12
+ const videoRef = useRef<HTMLVideoElement>(null);
13
+ const hlsRef = useRef<Hls | null>(null);
14
+ const [isPlaying, setIsPlaying] = useState(false);
15
+
16
+ const handlePlayClick = React.useCallback(
17
+ (e: React.MouseEvent) => {
18
+ e.stopPropagation();
19
+ if (videoRef.current) {
20
+ if (isPlaying) {
21
+ videoRef.current.pause();
22
+ } else {
23
+ videoRef.current.play();
24
+ }
25
+ }
26
+ },
27
+ [isPlaying]
28
+ );
29
+
30
+ useEffect(() => {
31
+ if (!videoRef.current || !media.file?.original) return;
32
+
33
+ const video = videoRef.current;
34
+ const videoUrl = media.file.original;
35
+
36
+ const handlePlay = () => {
37
+ setIsPlaying(true);
38
+ };
39
+
40
+ const handlePause = () => {
41
+ setIsPlaying(false);
42
+ };
43
+
44
+ video.addEventListener("play", handlePlay);
45
+ video.addEventListener("pause", handlePause);
46
+
47
+ if (media.storageType === "cloudflare_stream") {
48
+ if (!Hls.isSupported()) {
49
+ if (video.canPlayType("application/vnd.apple.mpegurl")) {
50
+ video.src = videoUrl;
51
+ }
52
+ } else {
53
+ const hls = new Hls({
54
+ enableWorker: true,
55
+ });
56
+
57
+ hls.loadSource(videoUrl);
58
+ hls.attachMedia(video);
59
+ hlsRef.current = hls;
60
+ }
61
+ } else {
62
+ video.src = videoUrl;
63
+ }
64
+
65
+ return () => {
66
+ video.removeEventListener("play", handlePlay);
67
+ video.removeEventListener("pause", handlePause);
68
+ if (hlsRef.current) {
69
+ hlsRef.current.destroy();
70
+ hlsRef.current = null;
71
+ }
72
+ };
73
+ }, [media.file?.original, media.storageType]);
74
+
75
+ return (
76
+ <div className="relative w-full h-full group cursor-pointer" onClick={handlePlayClick}>
77
+ <video
78
+ ref={videoRef}
79
+ preload="metadata"
80
+ poster={media.file?.thumbnail}
81
+ className="absolute inset-0 w-full h-full object-cover"
82
+ controls={false}
83
+ playsInline
84
+ />
85
+ {!isPlaying && (
86
+ <div className="absolute inset-0 flex items-center justify-center z-10 bg-black/10 group-hover:bg-black/20 transition-colors">
87
+ <div
88
+ className={clsx(
89
+ "rounded-full bg-white/90 hover:bg-white flex items-center justify-center shadow-lg transition-all hover:scale-110 backdrop-blur-sm",
90
+ size === "small" ? "w-8 h-8" : "w-14 h-14",
91
+ playButtonClass
92
+ )}
93
+ >
94
+ <svg
95
+ xmlns="http://www.w3.org/2000/svg"
96
+ viewBox="0 0 24 24"
97
+ fill="currentColor"
98
+ className={clsx(
99
+ "text-gray-900 ml-1",
100
+ size === "small" ? "w-4 h-4" : "w-8 h-8"
101
+ )}
102
+ >
103
+ <path
104
+ fillRule="evenodd"
105
+ d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.248L7.28 19.987C6.03 20.673 4.5 19.77 4.5 18.562V5.653z"
106
+ clipRule="evenodd"
107
+ />
108
+ </svg>
109
+ </div>
110
+ </div>
111
+ )}
112
+ </div>
113
+ );
114
+ }
@@ -1,18 +1,27 @@
1
1
  import clsx from "clsx";
2
2
  import { forwardRef, useEffect, useState, useCallback } from "react";
3
- import { TMedias } from "../../../entify";
3
+ import { TMedia } from "../../../entify";
4
+ import { MainMedia } from "./MainMedia";
5
+ import { Thumbnail } from "./Thumbnail";
4
6
 
5
7
  export type MediasProps = {
6
- value?: TMedias;
8
+ value?: TMedia[];
7
9
  className?: string;
8
10
  children?: React.ReactNode;
9
11
  // Aspect ratio, format is `aspect-[width/height]`
10
12
  aspect?: string;
11
13
  thumbnailAspect?: string;
14
+ enableZoom?: boolean;
15
+ thumbnailPosition?: "bottom" | "left";
16
+ visibleCount?: number;
12
17
  classNames?: {
13
18
  mainArea?: string;
14
19
  navigation?: string;
15
20
  thumbnail?: string;
21
+ thumbnailImage?: string;
22
+ arrowButton?: string;
23
+ arrowIcon?: string;
24
+ playButton?: string;
16
25
  };
17
26
  };
18
27
 
@@ -22,109 +31,56 @@ export const Medias = forwardRef<HTMLDivElement, MediasProps>((props, ref) => {
22
31
  className,
23
32
  children,
24
33
  aspect = "aspect-[1/1]",
25
- thumbnailAspect = "aspect-[5/4]",
34
+ thumbnailAspect = "aspect-[1/1]",
35
+ enableZoom = true,
36
+ thumbnailPosition = "bottom",
37
+ visibleCount = 6,
26
38
  classNames,
27
39
  ...rest
28
40
  } = props;
29
- const { mainArea, navigation, thumbnail } = classNames || {};
41
+ const {
42
+ mainArea,
43
+ navigation,
44
+ thumbnail,
45
+ thumbnailImage,
46
+ arrowButton,
47
+ arrowIcon,
48
+ playButton,
49
+ } = classNames || {};
30
50
  const [selectedId, setSelectedId] = useState<string | undefined | null>(
31
- value?.externalVideoUrl ? "video" : value?.medias?.[0]?.id || ""
51
+ value?.[0]?.id || ""
32
52
  );
33
- const [videoUrl, setVideoUrl] = useState<string>("");
34
- const [thumbnailUrl, setThumbnailUrl] = useState<string>("");
35
53
 
36
54
  useEffect(() => {
37
- setSelectedId(
38
- value?.externalVideoUrl ? "video" : value?.medias?.[0]?.id || ""
39
- );
55
+ setSelectedId(value?.[0]?.id || "");
40
56
  }, [value]);
41
57
 
42
- useEffect(() => {
43
- if (value?.externalVideoUrl) {
44
- const baseUrl = value.externalVideoUrl.replace(
45
- "https://youtu.be/",
46
- "https://www.youtube.com/embed/"
47
- );
48
- const separator = baseUrl.includes("?") ? "&" : "?";
49
- setVideoUrl(
50
- `${baseUrl}${separator}autoplay=1&muted=1&modestbranding=1&rel=0&controls=1&playsinline=1&enablejsapi=1&origin=${encodeURIComponent(
51
- window.location.origin
52
- )}`
53
- );
54
- }
55
- }, [value?.externalVideoUrl]);
56
-
57
- useEffect(() => {
58
- if (value?.externalVideoUrl) {
59
- const videoId = value.externalVideoUrl.includes("youtu.be/")
60
- ? value.externalVideoUrl.split("youtu.be/")[1].split("?")[0]
61
- : value.externalVideoUrl.split("v=")[1]?.split("&")[0];
62
-
63
- if (videoId) {
64
- setThumbnailUrl(`https://img.youtube.com/vi/${videoId}/default.jpg`);
65
- }
66
- }
67
- }, [value?.externalVideoUrl]);
58
+ const selectedIndex =
59
+ value?.findIndex((media) => media.id === selectedId) || 0;
68
60
 
69
- const selectedIndex = value?.externalVideoUrl
70
- ? selectedId === "video"
71
- ? 0
72
- : (value?.medias?.findIndex((media) => media.id === selectedId) || 0) + 1
73
- : value?.medias?.findIndex((media) => media.id === selectedId) || 0;
74
-
75
- const totalItems =
76
- (value?.externalVideoUrl ? 1 : 0) + (value?.medias?.length || 0);
77
-
78
- // Calculate visible thumbnails (show 6 items)
79
- const visibleCount = 6;
80
- const halfVisible = Math.floor(visibleCount / 2);
81
- const startIndex = Math.max(
82
- 0,
83
- Math.min(selectedIndex - halfVisible, totalItems - visibleCount)
84
- );
85
- const endIndex = Math.min(startIndex + visibleCount, totalItems);
61
+ const totalItems = value?.length || 0;
86
62
 
87
63
  const handlePrevious = useCallback(() => {
88
64
  if (selectedIndex > 0) {
89
- if (value?.externalVideoUrl) {
90
- if (selectedIndex === 1) {
91
- setSelectedId("video");
92
- } else {
93
- const prevIndex = selectedIndex - 2;
94
- setSelectedId(value.medias?.[prevIndex]?.id || "");
95
- }
96
- } else {
97
- const prevIndex = selectedIndex - 1;
98
- setSelectedId(value?.medias?.[prevIndex]?.id || "");
99
- }
65
+ const prevIndex = selectedIndex - 1;
66
+ setSelectedId(value?.[prevIndex]?.id || "");
100
67
  }
101
68
  }, [selectedIndex, value]);
102
69
 
103
70
  const handleNext = useCallback(() => {
104
71
  if (selectedIndex < totalItems - 1) {
105
- if (value?.externalVideoUrl) {
106
- if (selectedId === "video") {
107
- setSelectedId(value.medias?.[0]?.id || "");
108
- } else {
109
- const currentMediaIndex =
110
- value.medias?.findIndex((media) => media.id === selectedId) || 0;
111
- const nextIndex = currentMediaIndex + 1;
112
- setSelectedId(value.medias?.[nextIndex]?.id || "");
113
- }
114
- } else {
115
- const currentMediaIndex =
116
- value?.medias?.findIndex((media) => media.id === selectedId) || 0;
117
- const nextIndex = currentMediaIndex + 1;
118
- setSelectedId(value?.medias?.[nextIndex]?.id || "");
119
- }
72
+ const currentMediaIndex =
73
+ value?.findIndex((media) => media.id === selectedId) || 0;
74
+ const nextIndex = currentMediaIndex + 1;
75
+ setSelectedId(value?.[nextIndex]?.id || "");
120
76
  }
121
77
  }, [selectedIndex, totalItems, value, selectedId]);
122
78
 
123
79
  const handleKeyDown = useCallback(
124
80
  (e: KeyboardEvent) => {
125
- if (e.key === "ArrowLeft") {
81
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
126
82
  handlePrevious();
127
- } else if (e.key === "ArrowRight") {
83
+ } else if (e.key === "ArrowRight" || e.key === "ArrowDown") {
128
84
  handleNext();
129
85
  }
130
86
  },
@@ -139,214 +95,179 @@ export const Medias = forwardRef<HTMLDivElement, MediasProps>((props, ref) => {
139
95
  const canPrevious = selectedIndex > 0;
140
96
  const canNext = selectedIndex < totalItems - 1;
141
97
 
98
+ const isVerticalThumbs = thumbnailPosition === "left";
99
+
100
+ const actualVisibleCount = visibleCount;
101
+ const halfVisible = Math.floor(actualVisibleCount / 2);
102
+ const startIndex = Math.max(
103
+ 0,
104
+ Math.min(selectedIndex - halfVisible, totalItems - actualVisibleCount)
105
+ );
106
+ const endIndex = Math.min(startIndex + actualVisibleCount, totalItems);
107
+
142
108
  return (
143
- <div ref={ref} className={clsx("flex flex-col", className)} {...rest}>
109
+ <div
110
+ ref={ref}
111
+ className={clsx(
112
+ "flex gap-4",
113
+ isVerticalThumbs ? "flex-row-reverse" : "flex-col",
114
+ className
115
+ )}
116
+ {...rest}
117
+ >
144
118
  {children}
145
119
  {/* Main display area */}
146
- <div className={clsx("relative group", mainArea)}>
147
- {canPrevious && (
148
- <button
149
- onClick={handlePrevious}
150
- className="absolute left-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
151
- aria-label="Previous slide"
152
- >
153
- <svg
154
- xmlns="http://www.w3.org/2000/svg"
155
- className="h-6 w-6"
156
- fill="none"
157
- viewBox="0 0 24 24"
158
- stroke="currentColor"
159
- >
160
- <path
161
- strokeLinecap="round"
162
- strokeLinejoin="round"
163
- strokeWidth={2}
164
- d="M15 19l-7-7 7-7"
165
- />
166
- </svg>
167
- </button>
168
- )}
169
- {canNext && (
170
- <button
171
- onClick={handleNext}
172
- className="absolute right-4 top-1/2 transform -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
173
- aria-label="Next slide"
174
- >
175
- <svg
176
- xmlns="http://www.w3.org/2000/svg"
177
- className="h-6 w-6"
178
- fill="none"
179
- viewBox="0 0 24 24"
180
- stroke="currentColor"
181
- >
182
- <path
183
- strokeLinecap="round"
184
- strokeLinejoin="round"
185
- strokeWidth={2}
186
- d="M9 5l7 7-7 7"
187
- />
188
- </svg>
189
- </button>
190
- )}
191
- {value?.externalVideoUrl && selectedId === "video" && (
192
- <div className={clsx("w-full rounded-md overflow-hidden", aspect)}>
193
- <iframe
194
- src={videoUrl}
195
- className="w-full h-full"
196
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; autoplay"
197
- referrerPolicy="strict-origin-when-cross-origin"
198
- allowFullScreen
199
- />
200
- </div>
201
- )}
202
- {value?.medias?.map((media) => (
203
- <div
204
- key={media.id}
205
- className={clsx(
206
- "transition-opacity duration-300 overflow-hidden rounded-md",
207
- media.id === selectedId ? "opacity-100" : "opacity-0 hidden",
208
- aspect
209
- )}
210
- >
211
- <img
212
- src={media?.file?.resize || media?.file?.url}
213
- alt={media?.alt}
214
- className="w-full h-full object-cover object-center"
215
- />
216
- </div>
217
- ))}
218
- </div>
120
+ <MainMedia
121
+ value={value}
122
+ selectedId={selectedId}
123
+ aspect={aspect}
124
+ enableZoom={enableZoom}
125
+ className={clsx(isVerticalThumbs ? "flex-1" : "w-full", mainArea)}
126
+ arrowButtonClass={arrowButton}
127
+ arrowIconClass={arrowIcon}
128
+ playButtonClass={playButton}
129
+ onPrevious={handlePrevious}
130
+ onNext={handleNext}
131
+ canPrevious={canPrevious}
132
+ canNext={canNext}
133
+ />
219
134
 
220
135
  {/* Thumbnail navigation */}
221
136
  {totalItems > 1 && (
222
- <div className={clsx("relative mt-4", navigation)}>
223
- <div className="flex items-stretch gap-2">
224
- {totalItems > 6 && (
225
- <button
226
- onClick={handlePrevious}
227
- disabled={!canPrevious}
228
- className={clsx(
229
- "flex items-center justify-center w-6",
230
- "transition-colors duration-200 rounded-l-md",
231
- !canPrevious
232
- ? "bg-gray-100 text-gray-400 cursor-not-allowed"
233
- : "bg-gray-200 hover:bg-gray-300 text-gray-700",
234
- thumbnail
235
- )}
236
- aria-label="Previous"
137
+ <div
138
+ className={clsx(
139
+ "relative group/thumbs",
140
+ isVerticalThumbs ? "w-24 h-full px-1" : "w-full mt-4",
141
+ navigation
142
+ )}
143
+ >
144
+ {/* Previous Arrow */}
145
+ {totalItems > actualVisibleCount && (
146
+ <button
147
+ onClick={handlePrevious}
148
+ disabled={!canPrevious}
149
+ className={clsx(
150
+ "absolute z-10 w-8 h-8 flex items-center justify-center rounded-full",
151
+ "bg-white/60 dark:bg-black/50 shadow-sm backdrop-blur-sm",
152
+ "text-gray-700 dark:text-gray-200 transition-all duration-200",
153
+ isVerticalThumbs
154
+ ? "top-2 left-1/2 -translate-x-1/2"
155
+ : "left-2 top-1/2 -translate-y-1/2",
156
+ !canPrevious
157
+ ? "opacity-0 pointer-events-none"
158
+ : "opacity-0 group-hover/thumbs:opacity-100 hover:bg-white/90 dark:hover:bg-black/80",
159
+ arrowButton
160
+ )}
161
+ aria-label="Previous"
162
+ >
163
+ <svg
164
+ xmlns="http://www.w3.org/2000/svg"
165
+ className={clsx("h-4 w-4", arrowIcon)}
166
+ fill="none"
167
+ viewBox="0 0 24 24"
168
+ stroke="currentColor"
237
169
  >
238
- <svg
239
- xmlns="http://www.w3.org/2000/svg"
240
- className="h-4 w-4 md:h-5 md:w-5"
241
- fill="none"
242
- viewBox="0 0 24 24"
243
- stroke="currentColor"
244
- >
170
+ {isVerticalThumbs ? (
171
+ <path
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ strokeWidth={2}
175
+ d="M5 15l7-7 7 7"
176
+ />
177
+ ) : (
245
178
  <path
246
179
  strokeLinecap="round"
247
180
  strokeLinejoin="round"
248
181
  strokeWidth={2}
249
182
  d="M15 19l-7-7 7-7"
250
183
  />
251
- </svg>
252
- </button>
253
- )}
254
- <div className="flex-1">
255
- <div className="grid grid-cols-6 gap-2">
256
- {value?.externalVideoUrl && startIndex === 0 && (
257
- <div
258
- className={clsx(
259
- "relative cursor-pointer overflow-hidden rounded-sm border-2",
260
- thumbnailAspect,
261
- selectedId === "video"
262
- ? "border-primary-500"
263
- : "border-transparent hover:border-primary-300"
264
- )}
265
- onClick={() => setSelectedId("video")}
266
- >
267
- <img
268
- src={thumbnailUrl}
269
- alt="Video thumbnail"
270
- className="w-full h-full object-cover"
271
- />
272
- <div className="absolute inset-0 flex items-center justify-center bg-black/30">
273
- <svg
274
- xmlns="http://www.w3.org/2000/svg"
275
- className="h-8 w-8 text-white"
276
- viewBox="0 0 24 24"
277
- >
278
- <path
279
- fill="currentColor"
280
- fill-rule="evenodd"
281
- d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10"
282
- clipRule="evenodd"
283
- opacity="0.5"
284
- />
285
- <path
286
- fill="currentColor"
287
- d="m15.414 13.059l-4.72 2.787C9.934 16.294 9 15.71 9 14.786V9.214c0-.924.934-1.507 1.694-1.059l4.72 2.787c.781.462.781 1.656 0 2.118"
288
- />
289
- </svg>
290
- </div>
291
- </div>
292
184
  )}
293
- {value?.medias?.map((media, index) => {
294
- const adjustedIndex = value?.externalVideoUrl
295
- ? index + 1
296
- : index;
297
- return adjustedIndex >= startIndex &&
298
- adjustedIndex < endIndex ? (
299
- <div
300
- key={media.id}
301
- className={clsx(
302
- "relative cursor-pointer overflow-hidden rounded-sm border-2",
303
- thumbnailAspect,
304
- selectedId === media.id
305
- ? "border-primary-500"
306
- : "border-transparent hover:border-primary-300"
307
- )}
308
- onClick={() => setSelectedId(media.id)}
309
- >
310
- <img
311
- src={media?.file?.resize || media?.file?.url}
312
- alt={media?.alt}
313
- className="w-full h-full object-cover"
314
- />
315
- </div>
316
- ) : null;
317
- })}
318
- </div>
185
+ </svg>
186
+ </button>
187
+ )}
188
+
189
+ <div
190
+ className={clsx(
191
+ "flex-1",
192
+ isVerticalThumbs ? "h-full py-0.5" : "w-full px-0.5"
193
+ )}
194
+ >
195
+ <div
196
+ className={clsx(
197
+ "gap-3",
198
+ isVerticalThumbs ? "flex flex-col" : "grid"
199
+ )}
200
+ style={
201
+ !isVerticalThumbs
202
+ ? {
203
+ gridTemplateColumns: `repeat(${actualVisibleCount}, minmax(0, 1fr))`,
204
+ }
205
+ : undefined
206
+ }
207
+ >
208
+ {value?.map((media, index) => {
209
+ if (index < startIndex || index >= endIndex) return null;
210
+ const isSelected = selectedId === media.id;
211
+ return (
212
+ <Thumbnail
213
+ key={media.id}
214
+ media={media}
215
+ isSelected={isSelected}
216
+ onClick={() => setSelectedId(media.id)}
217
+ aspect={thumbnailAspect}
218
+ className={thumbnail}
219
+ imageClass={thumbnailImage}
220
+ playButtonClass={playButton}
221
+ />
222
+ );
223
+ })}
319
224
  </div>
320
- {totalItems > 6 && (
321
- <button
322
- onClick={handleNext}
323
- disabled={!canNext}
324
- className={clsx(
325
- "flex items-center justify-center w-6",
326
- "transition-colors duration-200 rounded-r-md",
327
- !canNext
328
- ? "bg-gray-100 text-gray-400 cursor-not-allowed"
329
- : "bg-gray-200 hover:bg-gray-300 text-gray-700"
330
- )}
331
- aria-label="Next"
225
+ </div>
226
+
227
+ {/* Next Arrow */}
228
+ {totalItems > actualVisibleCount && (
229
+ <button
230
+ onClick={handleNext}
231
+ disabled={!canNext}
232
+ className={clsx(
233
+ "absolute z-10 w-8 h-8 flex items-center justify-center rounded-full",
234
+ "bg-white/60 dark:bg-black/50 shadow-sm backdrop-blur-sm",
235
+ "text-gray-700 dark:text-gray-200 transition-all duration-200",
236
+ isVerticalThumbs
237
+ ? "bottom-2 left-1/2 -translate-x-1/2"
238
+ : "right-2 top-1/2 -translate-y-1/2",
239
+ !canNext
240
+ ? "opacity-0 pointer-events-none"
241
+ : "opacity-0 group-hover/thumbs:opacity-100 hover:bg-white/90 dark:hover:bg-black/80",
242
+ arrowButton
243
+ )}
244
+ aria-label="Next"
245
+ >
246
+ <svg
247
+ xmlns="http://www.w3.org/2000/svg"
248
+ className={clsx("h-4 w-4", arrowIcon)}
249
+ fill="none"
250
+ viewBox="0 0 24 24"
251
+ stroke="currentColor"
332
252
  >
333
- <svg
334
- xmlns="http://www.w3.org/2000/svg"
335
- className="h-4 w-4 md:h-5 md:w-5"
336
- fill="none"
337
- viewBox="0 0 24 24"
338
- stroke="currentColor"
339
- >
253
+ {isVerticalThumbs ? (
254
+ <path
255
+ strokeLinecap="round"
256
+ strokeLinejoin="round"
257
+ strokeWidth={2}
258
+ d="M19 9l-7 7-7-7"
259
+ />
260
+ ) : (
340
261
  <path
341
262
  strokeLinecap="round"
342
263
  strokeLinejoin="round"
343
264
  strokeWidth={2}
344
265
  d="M9 5l7 7-7 7"
345
266
  />
346
- </svg>
347
- </button>
348
- )}
349
- </div>
267
+ )}
268
+ </svg>
269
+ </button>
270
+ )}
350
271
  </div>
351
272
  )}
352
273
  </div>