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.
 
 
 

533 lines
21 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 applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
  19. v.muted = !!item.muted;
  20. };
  21. const togglePlay = (i: number) => {
  22. const v = videoRefs.current[i];
  23. if (!v) return;
  24. if (v.paused) {
  25. const item = items[i];
  26. if (item) applyVolume(v, item);
  27. v.play().catch(() => {});
  28. } else {
  29. v.pause();
  30. }
  31. };
  32. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  33. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  34. useEffect(() => {
  35. const onKey = (e: KeyboardEvent) => {
  36. if (e.key === 'ArrowLeft') prev();
  37. if (e.key === 'ArrowRight') next();
  38. };
  39. window.addEventListener('keydown', onKey);
  40. return () => window.removeEventListener('keydown', onKey);
  41. }, [prev, next]);
  42. useEffect(() => { setCurrent(0); }, [items]);
  43. // Pause non-current videos; autoplay current if flagged
  44. useEffect(() => {
  45. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  46. if (!vid) return;
  47. const idx = parseInt(key, 10);
  48. if (idx !== current) { vid.pause(); return; }
  49. const item = items[idx];
  50. if (item && isVideoUrl(item.url) && item.autoplay) {
  51. applyVolume(vid, item);
  52. vid.play().catch(() => {
  53. // Browser blocked unmuted autoplay — fall back to muted.
  54. vid.muted = true;
  55. vid.play().catch(() => {});
  56. });
  57. }
  58. });
  59. }, [current, items]);
  60. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  61. const onTouchEnd = (e: React.TouchEvent) => {
  62. if (touchStartX.current === null) return;
  63. const delta = e.changedTouches[0].clientX - touchStartX.current;
  64. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  65. touchStartX.current = null;
  66. };
  67. if (items.length === 0) {
  68. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  69. }
  70. return (
  71. <div
  72. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  73. onTouchStart={onTouchStart}
  74. onTouchEnd={onTouchEnd}
  75. >
  76. {items.map((item, i) => {
  77. const isActive = i === current;
  78. const video = isVideoUrl(item.url);
  79. return (
  80. <div
  81. key={item.url + i}
  82. className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  83. >
  84. {video ? (
  85. <div
  86. className="relative w-full h-full cursor-pointer"
  87. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  88. >
  89. <video
  90. ref={el => { videoRefs.current[i] = el; }}
  91. src={item.url}
  92. className="w-full h-full object-contain bg-black pointer-events-none"
  93. playsInline
  94. preload="metadata"
  95. onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
  96. onPlay={() => markPlaying(i)}
  97. onPause={() => markPaused(i)}
  98. onEnded={() => markPaused(i)}
  99. />
  100. {/* Custom play overlay (shown when paused) */}
  101. {!playing.has(i) && (
  102. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  103. <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>
  104. </div>
  105. )}
  106. {/* Custom expand button */}
  107. <button
  108. onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
  109. className="absolute bottom-3 right-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"
  110. title="Expand fullscreen"
  111. aria-label="Expand fullscreen"
  112. >
  113. <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
  114. <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
  115. </svg>
  116. </button>
  117. </div>
  118. ) : (
  119. <img
  120. src={item.url}
  121. alt=""
  122. className="w-full h-full object-cover cursor-zoom-in"
  123. onClick={() => onMediaClick?.(i)}
  124. title="Click to view fullscreen"
  125. />
  126. )}
  127. </div>
  128. );
  129. })}
  130. {items.length > 1 && (
  131. <>
  132. <button
  133. onClick={(e) => { e.stopPropagation(); prev(); }}
  134. 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"
  135. aria-label="Previous"
  136. >‹</button>
  137. <button
  138. onClick={(e) => { e.stopPropagation(); next(); }}
  139. 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"
  140. aria-label="Next"
  141. >›</button>
  142. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  143. {items.map((_, i) => (
  144. <button
  145. key={i}
  146. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  147. 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'}`}
  148. aria-label={`Go to slide ${i + 1}`}
  149. />
  150. ))}
  151. </div>
  152. <div className="absolute top-3 left-3 bg-black/60 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10 flex items-center gap-1">
  153. <span>⊞</span>
  154. <span>{current + 1} / {items.length}</span>
  155. </div>
  156. </>
  157. )}
  158. </div>
  159. );
  160. }
  161. function FullscreenViewer({
  162. items,
  163. startIndex,
  164. onClose,
  165. }: {
  166. items: MediaItem[];
  167. startIndex: number;
  168. onClose: () => void;
  169. }) {
  170. const [current, setCurrent] = useState(startIndex);
  171. const [playing, setPlaying] = useState<Set<number>>(new Set());
  172. const [zoom, setZoom] = useState(1);
  173. const [pan, setPan] = useState({ x: 0, y: 0 });
  174. const touchStartX = useRef<number | null>(null);
  175. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  176. const containerRef = useRef<HTMLDivElement | null>(null);
  177. const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null);
  178. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  179. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  180. const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
  181. v.muted = !!item.muted;
  182. };
  183. const togglePlay = (i: number) => {
  184. const v = videoRefs.current[i];
  185. if (!v) return;
  186. if (v.paused) {
  187. const item = items[i];
  188. if (item) applyVolume(v, item);
  189. v.play().catch(() => {});
  190. } else {
  191. v.pause();
  192. }
  193. };
  194. const onImgClick = (e: React.MouseEvent<HTMLImageElement>) => {
  195. if (dragRef.current?.moved) {
  196. dragRef.current = null;
  197. return;
  198. }
  199. dragRef.current = null;
  200. if (zoom > 1) {
  201. setZoom(1);
  202. setPan({ x: 0, y: 0 });
  203. } else {
  204. const r = e.currentTarget.getBoundingClientRect();
  205. const ox = e.clientX - r.left - r.width / 2;
  206. const oy = e.clientY - r.top - r.height / 2;
  207. setZoom(2);
  208. setPan({ x: -2 * ox, y: -2 * oy });
  209. }
  210. };
  211. const onImgPointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
  212. if (zoom <= 1) return;
  213. dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false };
  214. e.currentTarget.setPointerCapture?.(e.pointerId);
  215. };
  216. const onImgPointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
  217. if (!dragRef.current) return;
  218. const dx = e.clientX - dragRef.current.sx;
  219. const dy = e.clientY - dragRef.current.sy;
  220. if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true;
  221. setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy });
  222. };
  223. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  224. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  225. useEffect(() => {
  226. const onKey = (e: KeyboardEvent) => {
  227. if (e.key === 'Escape') onClose();
  228. else if (e.key === 'ArrowLeft') prev();
  229. else if (e.key === 'ArrowRight') next();
  230. };
  231. window.addEventListener('keydown', onKey);
  232. return () => window.removeEventListener('keydown', onKey);
  233. }, [prev, next, onClose]);
  234. // Pause non-current videos in fullscreen
  235. useEffect(() => {
  236. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  237. if (!vid) return;
  238. const idx = parseInt(key, 10);
  239. if (idx !== current) { vid.pause(); return; }
  240. const item = items[idx];
  241. if (item && isVideoUrl(item.url) && item.autoplay) {
  242. applyVolume(vid, item);
  243. vid.play().catch(() => {
  244. vid.muted = true;
  245. vid.play().catch(() => {});
  246. });
  247. }
  248. });
  249. }, [current, items]);
  250. // Reset zoom whenever the active slide changes
  251. useEffect(() => {
  252. setZoom(1);
  253. setPan({ x: 0, y: 0 });
  254. }, [current]);
  255. // Wheel zoom (only on images). preventDefault requires passive: false → manual listener.
  256. useEffect(() => {
  257. const el = containerRef.current;
  258. if (!el) return;
  259. const onWheel = (e: WheelEvent) => {
  260. const item = items[current];
  261. if (!item || isVideoUrl(item.url)) return;
  262. e.preventDefault();
  263. const factor = 1 - e.deltaY * 0.001;
  264. setZoom(prev => {
  265. const next = Math.max(1, Math.min(4, prev * factor));
  266. if (next === 1) setPan({ x: 0, y: 0 });
  267. return next;
  268. });
  269. };
  270. el.addEventListener('wheel', onWheel, { passive: false });
  271. return () => el.removeEventListener('wheel', onWheel);
  272. }, [current, items]);
  273. const onTouchStart = (e: React.TouchEvent) => {
  274. if (zoom > 1) return; // pan via pointer events instead
  275. touchStartX.current = e.touches[0].clientX;
  276. };
  277. const onTouchEnd = (e: React.TouchEvent) => {
  278. if (zoom > 1) return;
  279. if (touchStartX.current === null) return;
  280. const delta = e.changedTouches[0].clientX - touchStartX.current;
  281. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  282. touchStartX.current = null;
  283. };
  284. return (
  285. <div
  286. ref={containerRef}
  287. className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
  288. onTouchStart={onTouchStart}
  289. onTouchEnd={onTouchEnd}
  290. >
  291. {/* Media — full resolution, contained */}
  292. {items.map((item, i) => {
  293. const isActive = i === current;
  294. const video = isVideoUrl(item.url);
  295. return (
  296. <div
  297. key={item.url + i}
  298. 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'}`}
  299. >
  300. {video ? (
  301. <div
  302. className="relative flex items-center justify-center max-w-full max-h-full cursor-pointer"
  303. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  304. >
  305. <video
  306. ref={el => { videoRefs.current[i] = el; }}
  307. src={item.url}
  308. className="max-w-full max-h-full pointer-events-none"
  309. playsInline
  310. preload="metadata"
  311. onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
  312. onPlay={() => markPlaying(i)}
  313. onPause={() => markPaused(i)}
  314. onEnded={() => markPaused(i)}
  315. />
  316. {!playing.has(i) && (
  317. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  318. <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>
  319. </div>
  320. )}
  321. </div>
  322. ) : (
  323. <img
  324. src={item.url}
  325. alt=""
  326. className="max-w-full max-h-full object-contain"
  327. draggable={false}
  328. style={isActive ? {
  329. transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
  330. cursor: zoom > 1 ? 'grab' : 'zoom-in',
  331. transition: dragRef.current ? 'none' : 'transform 0.2s',
  332. touchAction: zoom > 1 ? 'none' : 'auto',
  333. willChange: 'transform',
  334. } : undefined}
  335. onClick={isActive ? onImgClick : undefined}
  336. onPointerDown={isActive ? onImgPointerDown : undefined}
  337. onPointerMove={isActive ? onImgPointerMove : undefined}
  338. />
  339. )}
  340. </div>
  341. );
  342. })}
  343. {/* Counter — top center */}
  344. {items.length > 1 && (
  345. <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">
  346. {current + 1} / {items.length}
  347. </div>
  348. )}
  349. {/* Side arrows */}
  350. {items.length > 1 && (
  351. <>
  352. <button
  353. onClick={(e) => { e.stopPropagation(); prev(); }}
  354. 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"
  355. aria-label="Previous"
  356. >‹</button>
  357. <button
  358. onClick={(e) => { e.stopPropagation(); next(); }}
  359. 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"
  360. aria-label="Next"
  361. >›</button>
  362. </>
  363. )}
  364. {/* Close button — bottom center, ABOVE the dots */}
  365. <button
  366. onClick={onClose}
  367. 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"
  368. aria-label="Close fullscreen"
  369. >
  370. <span className="text-lg leading-none">✕</span>
  371. <span>Close</span>
  372. </button>
  373. {/* Dots — at the very bottom */}
  374. {items.length > 1 && (
  375. <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
  376. {items.map((_, i) => (
  377. <button
  378. key={i}
  379. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  380. 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'}`}
  381. aria-label={`Go to slide ${i + 1}`}
  382. />
  383. ))}
  384. </div>
  385. )}
  386. </div>
  387. );
  388. }
  389. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  390. const [activeCard, setActiveCard] = useState<Card | null>(null);
  391. const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);
  392. useEffect(() => {
  393. if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
  394. else document.body.style.overflow = 'unset';
  395. return () => { document.body.style.overflow = 'unset'; };
  396. }, [activeCard, fullscreenIndex]);
  397. const gridClasses: Record<number, string> = {
  398. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  399. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  400. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  401. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  402. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  403. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  404. };
  405. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  406. const carouselItems: MediaItem[] = activeCard
  407. ? [
  408. ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []),
  409. ...(activeCard.extraMedia || []),
  410. ]
  411. : [];
  412. return (
  413. <>
  414. <div className={`grid gap-4 ${activeGridClass}`}>
  415. {cards.map((card) => {
  416. const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
  417. return (
  418. <div
  419. key={card.id}
  420. onClick={() => {
  421. setActiveCard(card);
  422. if (card.autoFullscreen) setFullscreenIndex(0);
  423. }}
  424. 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"
  425. >
  426. {card.imageUrl ? (
  427. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  428. ) : (
  429. <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>
  430. )}
  431. {galleryCount > 1 && (
  432. <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">
  433. <span>⊞</span>
  434. <span>{galleryCount}</span>
  435. </div>
  436. )}
  437. <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">
  438. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  439. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  440. {card.shortDescription}
  441. </p>
  442. </div>
  443. </div>
  444. );
  445. })}
  446. </div>
  447. {activeCard && fullscreenIndex === null && (
  448. <div
  449. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  450. onClick={() => setActiveCard(null)}
  451. >
  452. <div
  453. 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"
  454. onClick={(e) => e.stopPropagation()}
  455. >
  456. <div className="relative">
  457. <MediaCarousel
  458. items={carouselItems}
  459. onMediaClick={(i) => setFullscreenIndex(i)}
  460. />
  461. <button
  462. onClick={() => setActiveCard(null)}
  463. 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"
  464. title="Close"
  465. >✕</button>
  466. </div>
  467. <div className="p-8">
  468. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  469. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  470. {activeCard.fullContent ? (
  471. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  472. ) : (
  473. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  474. )}
  475. </div>
  476. </div>
  477. </div>
  478. )}
  479. {fullscreenIndex !== null && activeCard && (
  480. <FullscreenViewer
  481. items={carouselItems}
  482. startIndex={fullscreenIndex}
  483. onClose={() => {
  484. setFullscreenIndex(null);
  485. setActiveCard(null);
  486. }}
  487. />
  488. )}
  489. </>
  490. );
  491. }