@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 +5 -5
- package/src/entify/Entify.ts +2 -2
- package/src/entify/lib/newQueryProductOptions.ts +3 -2
- package/src/entify/view-model/funcs.ts +10 -13
- package/src/entify/view-model/models.ts +1 -6
- package/src/react/components/Medias/MainMedia.tsx +237 -0
- package/src/react/components/Medias/Thumbnail.tsx +62 -0
- package/src/react/components/Medias/VideoPlayer.tsx +114 -0
- package/src/react/components/Medias/index.tsx +188 -267
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rxdrag/website-lib-core",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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.
|
|
40
|
-
"@rxdrag/rxcms-models": "0.3.
|
|
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"
|
package/src/entify/Entify.ts
CHANGED
|
@@ -125,7 +125,7 @@ export class Entify implements IEntify {
|
|
|
125
125
|
addonFields?: (keyof Product)[]
|
|
126
126
|
) {
|
|
127
127
|
if (!imageSize) {
|
|
128
|
-
imageSize = this.imageSizes
|
|
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
|
|
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
|
-
|
|
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):
|
|
23
|
+
export function mediasToViewModel(product?: Product): TMedia[] | undefined {
|
|
24
24
|
if (!product) {
|
|
25
25
|
return undefined;
|
|
26
26
|
}
|
|
27
|
-
return {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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?.
|
|
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?:
|
|
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 {
|
|
3
|
+
import { TMedia } from "../../../entify";
|
|
4
|
+
import { MainMedia } from "./MainMedia";
|
|
5
|
+
import { Thumbnail } from "./Thumbnail";
|
|
4
6
|
|
|
5
7
|
export type MediasProps = {
|
|
6
|
-
value?:
|
|
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-[
|
|
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 {
|
|
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?.
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
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
|
-
<
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
347
|
-
</
|
|
348
|
-
|
|
349
|
-
|
|
267
|
+
)}
|
|
268
|
+
</svg>
|
|
269
|
+
</button>
|
|
270
|
+
)}
|
|
350
271
|
</div>
|
|
351
272
|
)}
|
|
352
273
|
</div>
|