You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

410 line
16 KiB

  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { Card, MediaItem } from '@/types';
  4. const isVideoUrl = (url: string) => /\.(mp4|webm|mov|m4v|ogv)(\?|$)/i.test(url);
  5. function MediaCarousel({
  6. items,
  7. onMediaClick,
  8. }: {
  9. items: MediaItem[];
  10. onMediaClick?: (index: number) => void;
  11. }) {
  12. const [current, setCurrent] = useState(0);
  13. const [playing, setPlaying] = useState<Set<number>>(new Set());
  14. const touchStartX = useRef<number | null>(null);
  15. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  16. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  17. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  18. const togglePlay = (i: number) => {
  19. const v = videoRefs.current[i];
  20. if (!v) return;
  21. if (v.paused) v.play().catch(() => {});
  22. else v.pause();
  23. };
  24. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  25. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  26. useEffect(() => {
  27. const onKey = (e: KeyboardEvent) => {
  28. if (e.key === 'ArrowLeft') prev();
  29. if (e.key === 'ArrowRight') next();
  30. };
  31. window.addEventListener('keydown', onKey);
  32. return () => window.removeEventListener('keydown', onKey);
  33. }, [prev, next]);
  34. useEffect(() => { setCurrent(0); }, [items]);
  35. // Pause non-current videos; autoplay current if flagged
  36. useEffect(() => {
  37. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  38. if (!vid) return;
  39. const idx = parseInt(key, 10);
  40. if (idx !== current) { vid.pause(); return; }
  41. const item = items[idx];
  42. if (item && isVideoUrl(item.url) && item.autoplay) {
  43. vid.muted = true;
  44. vid.play().catch(() => {});
  45. }
  46. });
  47. }, [current, items]);
  48. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  49. const onTouchEnd = (e: React.TouchEvent) => {
  50. if (touchStartX.current === null) return;
  51. const delta = e.changedTouches[0].clientX - touchStartX.current;
  52. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  53. touchStartX.current = null;
  54. };
  55. if (items.length === 0) {
  56. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  57. }
  58. return (
  59. <div
  60. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  61. onTouchStart={onTouchStart}
  62. onTouchEnd={onTouchEnd}
  63. >
  64. {items.map((item, i) => {
  65. const isActive = i === current;
  66. const video = isVideoUrl(item.url);
  67. return (
  68. <div
  69. key={item.url + i}
  70. className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  71. >
  72. {video ? (
  73. <div
  74. className="relative w-full h-full cursor-pointer"
  75. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  76. >
  77. <video
  78. ref={el => { videoRefs.current[i] = el; }}
  79. src={item.url}
  80. className="w-full h-full object-contain bg-black pointer-events-none"
  81. playsInline
  82. muted={!!item.autoplay}
  83. preload="metadata"
  84. onPlay={() => markPlaying(i)}
  85. onPause={() => markPaused(i)}
  86. onEnded={() => markPaused(i)}
  87. />
  88. {/* Custom play overlay (shown when paused) */}
  89. {!playing.has(i) && (
  90. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  91. <span className="bg-black/60 text-white w-20 h-20 rounded-full flex items-center justify-center text-4xl shadow-2xl pl-1">▶</span>
  92. </div>
  93. )}
  94. {/* Custom expand button */}
  95. <button
  96. onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
  97. className="absolute top-3 left-3 bg-black/60 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
  98. title="Expand fullscreen"
  99. aria-label="Expand fullscreen"
  100. >
  101. <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
  102. <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
  103. </svg>
  104. </button>
  105. </div>
  106. ) : (
  107. <img
  108. src={item.url}
  109. alt=""
  110. className="w-full h-full object-cover cursor-zoom-in"
  111. onClick={() => onMediaClick?.(i)}
  112. title="Click to view fullscreen"
  113. />
  114. )}
  115. </div>
  116. );
  117. })}
  118. {items.length > 1 && (
  119. <>
  120. <button
  121. onClick={(e) => { e.stopPropagation(); prev(); }}
  122. className="absolute left-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
  123. aria-label="Previous"
  124. >‹</button>
  125. <button
  126. onClick={(e) => { e.stopPropagation(); next(); }}
  127. className="absolute right-3 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-9 h-9 rounded-full flex items-center justify-center transition-colors shadow-lg z-10"
  128. aria-label="Next"
  129. >›</button>
  130. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  131. {items.map((_, i) => (
  132. <button
  133. key={i}
  134. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  135. className={`rounded-full transition-all duration-200 ${i === current ? 'w-5 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`}
  136. aria-label={`Go to slide ${i + 1}`}
  137. />
  138. ))}
  139. </div>
  140. <div className="absolute top-3 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10">
  141. {current + 1} / {items.length}
  142. </div>
  143. </>
  144. )}
  145. </div>
  146. );
  147. }
  148. function FullscreenViewer({
  149. items,
  150. startIndex,
  151. onClose,
  152. }: {
  153. items: MediaItem[];
  154. startIndex: number;
  155. onClose: () => void;
  156. }) {
  157. const [current, setCurrent] = useState(startIndex);
  158. const [playing, setPlaying] = useState<Set<number>>(new Set());
  159. const touchStartX = useRef<number | null>(null);
  160. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  161. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  162. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  163. const togglePlay = (i: number) => {
  164. const v = videoRefs.current[i];
  165. if (!v) return;
  166. if (v.paused) v.play().catch(() => {});
  167. else v.pause();
  168. };
  169. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  170. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  171. useEffect(() => {
  172. const onKey = (e: KeyboardEvent) => {
  173. if (e.key === 'Escape') onClose();
  174. else if (e.key === 'ArrowLeft') prev();
  175. else if (e.key === 'ArrowRight') next();
  176. };
  177. window.addEventListener('keydown', onKey);
  178. return () => window.removeEventListener('keydown', onKey);
  179. }, [prev, next, onClose]);
  180. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  181. const onTouchEnd = (e: React.TouchEvent) => {
  182. if (touchStartX.current === null) return;
  183. const delta = e.changedTouches[0].clientX - touchStartX.current;
  184. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  185. touchStartX.current = null;
  186. };
  187. return (
  188. <div
  189. className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
  190. onTouchStart={onTouchStart}
  191. onTouchEnd={onTouchEnd}
  192. >
  193. {/* Media — full resolution, contained */}
  194. {items.map((item, i) => {
  195. const isActive = i === current;
  196. const video = isVideoUrl(item.url);
  197. return (
  198. <div
  199. key={item.url + i}
  200. className={`absolute inset-0 flex items-center justify-center p-4 pb-28 transition-opacity duration-200 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  201. >
  202. {video ? (
  203. <div
  204. className="relative flex items-center justify-center max-w-full max-h-full cursor-pointer"
  205. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  206. >
  207. <video
  208. ref={el => { videoRefs.current[i] = el; }}
  209. src={item.url}
  210. className="max-w-full max-h-full pointer-events-none"
  211. playsInline
  212. autoPlay={!!item.autoplay}
  213. muted={!!item.autoplay}
  214. onPlay={() => markPlaying(i)}
  215. onPause={() => markPaused(i)}
  216. onEnded={() => markPaused(i)}
  217. />
  218. {!playing.has(i) && (
  219. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  220. <span className="bg-black/60 text-white w-24 h-24 rounded-full flex items-center justify-center text-5xl shadow-2xl pl-1">▶</span>
  221. </div>
  222. )}
  223. </div>
  224. ) : (
  225. <img
  226. src={item.url}
  227. alt=""
  228. className="max-w-full max-h-full object-contain"
  229. />
  230. )}
  231. </div>
  232. );
  233. })}
  234. {/* Counter — top center */}
  235. {items.length > 1 && (
  236. <div className="absolute top-4 left-1/2 -translate-x-1/2 bg-black/60 text-white text-sm font-semibold px-3 py-1 rounded-full z-20">
  237. {current + 1} / {items.length}
  238. </div>
  239. )}
  240. {/* Side arrows */}
  241. {items.length > 1 && (
  242. <>
  243. <button
  244. onClick={(e) => { e.stopPropagation(); prev(); }}
  245. className="absolute left-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20"
  246. aria-label="Previous"
  247. >‹</button>
  248. <button
  249. onClick={(e) => { e.stopPropagation(); next(); }}
  250. className="absolute right-4 top-1/2 -translate-y-1/2 bg-black/50 hover:bg-black/80 text-white w-12 h-12 rounded-full flex items-center justify-center text-2xl shadow-lg z-20"
  251. aria-label="Next"
  252. >›</button>
  253. </>
  254. )}
  255. {/* Close button — bottom center, ABOVE the dots */}
  256. <button
  257. onClick={onClose}
  258. className="absolute bottom-12 left-1/2 -translate-x-1/2 bg-white/90 hover:bg-white text-black px-6 py-2.5 rounded-full font-semibold shadow-2xl flex items-center gap-2 z-20 transition-transform hover:scale-105"
  259. aria-label="Close fullscreen"
  260. >
  261. <span className="text-lg leading-none">✕</span>
  262. <span>Close</span>
  263. </button>
  264. {/* Dots — at the very bottom */}
  265. {items.length > 1 && (
  266. <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
  267. {items.map((_, i) => (
  268. <button
  269. key={i}
  270. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  271. className={`rounded-full transition-all duration-200 ${i === current ? 'w-6 h-2 bg-white' : 'w-2 h-2 bg-white/50 hover:bg-white/80'}`}
  272. aria-label={`Go to slide ${i + 1}`}
  273. />
  274. ))}
  275. </div>
  276. )}
  277. </div>
  278. );
  279. }
  280. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  281. const [activeCard, setActiveCard] = useState<Card | null>(null);
  282. const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);
  283. useEffect(() => {
  284. if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
  285. else document.body.style.overflow = 'unset';
  286. return () => { document.body.style.overflow = 'unset'; };
  287. }, [activeCard, fullscreenIndex]);
  288. const gridClasses: Record<number, string> = {
  289. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  290. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  291. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  292. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  293. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  294. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  295. };
  296. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  297. const carouselItems: MediaItem[] = activeCard
  298. ? [
  299. ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []),
  300. ...(activeCard.extraMedia || []),
  301. ]
  302. : [];
  303. return (
  304. <>
  305. <div className={`grid gap-4 ${activeGridClass}`}>
  306. {cards.map((card) => {
  307. const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
  308. return (
  309. <div
  310. key={card.id}
  311. onClick={() => setActiveCard(card)}
  312. className="group relative cursor-pointer overflow-hidden rounded-xl shadow-md aspect-square bg-gray-200 transition-all duration-300 hover:shadow-xl hover:-translate-y-1"
  313. >
  314. {card.imageUrl ? (
  315. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  316. ) : (
  317. <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-blue-100 to-gray-200 text-gray-400">No Image</div>
  318. )}
  319. {galleryCount > 1 && (
  320. <div className="absolute top-2 left-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1 z-10">
  321. <span>⊞</span>
  322. <span>{galleryCount}</span>
  323. </div>
  324. )}
  325. <div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent flex flex-col justify-end p-5 text-white">
  326. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  327. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  328. {card.shortDescription}
  329. </p>
  330. </div>
  331. </div>
  332. );
  333. })}
  334. </div>
  335. {activeCard && (
  336. <div
  337. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  338. onClick={() => setActiveCard(null)}
  339. >
  340. <div
  341. className="bg-white rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto shadow-2xl animate-in fade-in zoom-in-95 duration-200"
  342. onClick={(e) => e.stopPropagation()}
  343. >
  344. <div className="relative">
  345. <MediaCarousel
  346. items={carouselItems}
  347. onMediaClick={(i) => setFullscreenIndex(i)}
  348. />
  349. <button
  350. onClick={() => setActiveCard(null)}
  351. className="absolute top-4 right-4 bg-black/60 text-white w-10 h-10 flex items-center justify-center rounded-full hover:bg-black hover:scale-110 transition-all shadow-lg z-20"
  352. title="Close"
  353. >✕</button>
  354. </div>
  355. <div className="p-8">
  356. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  357. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  358. {activeCard.fullContent ? (
  359. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  360. ) : (
  361. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  362. )}
  363. </div>
  364. </div>
  365. </div>
  366. )}
  367. {fullscreenIndex !== null && activeCard && (
  368. <FullscreenViewer
  369. items={carouselItems}
  370. startIndex={fullscreenIndex}
  371. onClose={() => setFullscreenIndex(null)}
  372. />
  373. )}
  374. </>
  375. );
  376. }