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.

762 lines
32 KiB

  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
  3. import { Card, MediaItem } from '@/types';
  4. import { withBasePath } from '@/lib/url';
  5. const VIDEO_EXTENSIONS = 'mp4|m4v|webm|mov|qt|mkv|avi|divx|wmv|asf|flv|f4v|3gp|3gpp|3g2|mts|m2ts|ts|mpg|mpeg|vob|mxf|ogv|ogg';
  6. const isVideoUrl = (url: string) => new RegExp(`\\.(${VIDEO_EXTENSIONS})(\\?|$)`, 'i').test(url);
  7. function MediaCarousel({
  8. items,
  9. onMediaClick,
  10. }: {
  11. items: MediaItem[];
  12. onMediaClick?: (index: number) => void;
  13. }) {
  14. const [current, setCurrent] = useState(0);
  15. const [playing, setPlaying] = useState<Set<number>>(new Set());
  16. const touchStartX = useRef<number | null>(null);
  17. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  18. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  19. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  20. const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
  21. v.muted = !!item.muted;
  22. };
  23. const togglePlay = (i: number) => {
  24. const v = videoRefs.current[i];
  25. if (!v) return;
  26. if (v.paused) {
  27. const item = items[i];
  28. if (item) applyVolume(v, item);
  29. v.play().catch(() => {});
  30. } else {
  31. v.pause();
  32. }
  33. };
  34. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  35. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  36. useEffect(() => {
  37. const onKey = (e: KeyboardEvent) => {
  38. if (e.key === 'ArrowLeft') prev();
  39. if (e.key === 'ArrowRight') next();
  40. };
  41. window.addEventListener('keydown', onKey);
  42. return () => window.removeEventListener('keydown', onKey);
  43. }, [prev, next]);
  44. useEffect(() => { setCurrent(0); }, [items]);
  45. // Pause non-current videos; autoplay current if flagged
  46. useEffect(() => {
  47. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  48. if (!vid) return;
  49. const idx = parseInt(key, 10);
  50. if (idx !== current) { vid.pause(); return; }
  51. const item = items[idx];
  52. if (item && isVideoUrl(item.url) && item.autoplay) {
  53. applyVolume(vid, item);
  54. vid.play().catch(() => {
  55. // Browser blocked unmuted autoplay — fall back to muted.
  56. vid.muted = true;
  57. vid.play().catch(() => {});
  58. });
  59. }
  60. });
  61. }, [current, items]);
  62. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  63. const onTouchEnd = (e: React.TouchEvent) => {
  64. if (touchStartX.current === null) return;
  65. const delta = e.changedTouches[0].clientX - touchStartX.current;
  66. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  67. touchStartX.current = null;
  68. };
  69. if (items.length === 0) {
  70. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  71. }
  72. return (
  73. <div
  74. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  75. onTouchStart={onTouchStart}
  76. onTouchEnd={onTouchEnd}
  77. >
  78. {items.map((item, i) => {
  79. const isActive = i === current;
  80. const video = isVideoUrl(item.url);
  81. return (
  82. <div
  83. key={item.url + i}
  84. className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  85. >
  86. {video ? (
  87. <div
  88. className="relative w-full h-full cursor-pointer"
  89. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  90. >
  91. <video
  92. ref={el => { videoRefs.current[i] = el; }}
  93. src={item.url}
  94. className="w-full h-full object-contain bg-black pointer-events-none"
  95. playsInline
  96. preload="metadata"
  97. onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
  98. onPlay={() => markPlaying(i)}
  99. onPause={() => markPaused(i)}
  100. onEnded={() => markPaused(i)}
  101. />
  102. {/* Custom play overlay (shown when paused) */}
  103. {!playing.has(i) && (
  104. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  105. <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>
  106. </div>
  107. )}
  108. {/* Custom expand button */}
  109. <button
  110. onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
  111. 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"
  112. title="Expand fullscreen"
  113. aria-label="Expand fullscreen"
  114. >
  115. <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
  116. <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
  117. </svg>
  118. </button>
  119. </div>
  120. ) : (
  121. <img
  122. src={item.url}
  123. alt=""
  124. className="w-full h-full object-cover cursor-zoom-in"
  125. onClick={() => onMediaClick?.(i)}
  126. title="Click to view fullscreen"
  127. />
  128. )}
  129. </div>
  130. );
  131. })}
  132. {items.length > 1 && (
  133. <>
  134. <button
  135. onClick={(e) => { e.stopPropagation(); prev(); }}
  136. 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"
  137. aria-label="Previous"
  138. >‹</button>
  139. <button
  140. onClick={(e) => { e.stopPropagation(); next(); }}
  141. 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"
  142. aria-label="Next"
  143. >›</button>
  144. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  145. {items.map((_, i) => (
  146. <button
  147. key={i}
  148. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  149. 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'}`}
  150. aria-label={`Go to slide ${i + 1}`}
  151. />
  152. ))}
  153. </div>
  154. <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">
  155. <span>⊞</span>
  156. <span>{current + 1} / {items.length}</span>
  157. </div>
  158. </>
  159. )}
  160. </div>
  161. );
  162. }
  163. function FullscreenViewer({
  164. items,
  165. startIndex,
  166. onClose,
  167. }: {
  168. items: MediaItem[];
  169. startIndex: number;
  170. onClose: () => void;
  171. }) {
  172. const [current, setCurrent] = useState(startIndex);
  173. const [playing, setPlaying] = useState<Set<number>>(new Set());
  174. const [zoom, setZoom] = useState(1);
  175. const [pan, setPan] = useState({ x: 0, y: 0 });
  176. const touchStartX = useRef<number | null>(null);
  177. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  178. const containerRef = useRef<HTMLDivElement | null>(null);
  179. const dragRef = useRef<{ sx: number; sy: number; px: number; py: number; moved: boolean } | null>(null);
  180. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  181. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  182. const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
  183. v.muted = !!item.muted;
  184. };
  185. const togglePlay = (i: number) => {
  186. const v = videoRefs.current[i];
  187. if (!v) return;
  188. if (v.paused) {
  189. const item = items[i];
  190. if (item) applyVolume(v, item);
  191. v.play().catch(() => {});
  192. } else {
  193. v.pause();
  194. }
  195. };
  196. const onImgClick = (e: React.MouseEvent<HTMLImageElement>) => {
  197. if (dragRef.current?.moved) {
  198. dragRef.current = null;
  199. return;
  200. }
  201. dragRef.current = null;
  202. if (zoom > 1) {
  203. setZoom(1);
  204. setPan({ x: 0, y: 0 });
  205. } else {
  206. const r = e.currentTarget.getBoundingClientRect();
  207. const ox = e.clientX - r.left - r.width / 2;
  208. const oy = e.clientY - r.top - r.height / 2;
  209. setZoom(2);
  210. setPan({ x: -2 * ox, y: -2 * oy });
  211. }
  212. };
  213. const onImgPointerDown = (e: React.PointerEvent<HTMLImageElement>) => {
  214. if (zoom <= 1) return;
  215. dragRef.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y, moved: false };
  216. e.currentTarget.setPointerCapture?.(e.pointerId);
  217. };
  218. const onImgPointerMove = (e: React.PointerEvent<HTMLImageElement>) => {
  219. if (!dragRef.current) return;
  220. const dx = e.clientX - dragRef.current.sx;
  221. const dy = e.clientY - dragRef.current.sy;
  222. if (Math.hypot(dx, dy) > 4) dragRef.current.moved = true;
  223. setPan({ x: dragRef.current.px + dx, y: dragRef.current.py + dy });
  224. };
  225. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  226. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  227. useEffect(() => {
  228. const onKey = (e: KeyboardEvent) => {
  229. if (e.key === 'Escape') onClose();
  230. else if (e.key === 'ArrowLeft') prev();
  231. else if (e.key === 'ArrowRight') next();
  232. };
  233. window.addEventListener('keydown', onKey);
  234. return () => window.removeEventListener('keydown', onKey);
  235. }, [prev, next, onClose]);
  236. // Pause non-current videos in fullscreen
  237. useEffect(() => {
  238. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  239. if (!vid) return;
  240. const idx = parseInt(key, 10);
  241. if (idx !== current) { vid.pause(); return; }
  242. const item = items[idx];
  243. if (item && isVideoUrl(item.url) && item.autoplay) {
  244. applyVolume(vid, item);
  245. vid.play().catch(() => {
  246. vid.muted = true;
  247. vid.play().catch(() => {});
  248. });
  249. }
  250. });
  251. }, [current, items]);
  252. // Reset zoom whenever the active slide changes
  253. useEffect(() => {
  254. setZoom(1);
  255. setPan({ x: 0, y: 0 });
  256. }, [current]);
  257. // Wheel zoom (only on images). preventDefault requires passive: false → manual listener.
  258. useEffect(() => {
  259. const el = containerRef.current;
  260. if (!el) return;
  261. const onWheel = (e: WheelEvent) => {
  262. const item = items[current];
  263. if (!item || isVideoUrl(item.url)) return;
  264. e.preventDefault();
  265. const factor = 1 - e.deltaY * 0.001;
  266. setZoom(prev => {
  267. const next = Math.max(1, Math.min(4, prev * factor));
  268. if (next === 1) setPan({ x: 0, y: 0 });
  269. return next;
  270. });
  271. };
  272. el.addEventListener('wheel', onWheel, { passive: false });
  273. return () => el.removeEventListener('wheel', onWheel);
  274. }, [current, items]);
  275. const onTouchStart = (e: React.TouchEvent) => {
  276. if (zoom > 1) return; // pan via pointer events instead
  277. touchStartX.current = e.touches[0].clientX;
  278. };
  279. const onTouchEnd = (e: React.TouchEvent) => {
  280. if (zoom > 1) return;
  281. if (touchStartX.current === null) return;
  282. const delta = e.changedTouches[0].clientX - touchStartX.current;
  283. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  284. touchStartX.current = null;
  285. };
  286. return (
  287. <div
  288. ref={containerRef}
  289. className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
  290. onTouchStart={onTouchStart}
  291. onTouchEnd={onTouchEnd}
  292. >
  293. {/* Media — full resolution, contained */}
  294. {items.map((item, i) => {
  295. const isActive = i === current;
  296. const video = isVideoUrl(item.url);
  297. return (
  298. <div
  299. key={item.url + i}
  300. 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'}`}
  301. >
  302. {video ? (
  303. <div
  304. className="relative w-full h-full flex items-center justify-center cursor-pointer"
  305. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  306. >
  307. <video
  308. ref={el => { videoRefs.current[i] = el; }}
  309. src={item.url}
  310. className="max-w-full max-h-full object-contain pointer-events-none"
  311. playsInline
  312. preload="metadata"
  313. onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
  314. onPlay={() => markPlaying(i)}
  315. onPause={() => markPaused(i)}
  316. onEnded={() => markPaused(i)}
  317. />
  318. {!playing.has(i) && (
  319. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  320. <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>
  321. </div>
  322. )}
  323. </div>
  324. ) : (
  325. <img
  326. src={item.url}
  327. alt=""
  328. className="max-w-full max-h-full object-contain"
  329. draggable={false}
  330. style={isActive ? {
  331. transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
  332. cursor: zoom > 1 ? 'grab' : 'zoom-in',
  333. transition: dragRef.current ? 'none' : 'transform 0.2s',
  334. touchAction: zoom > 1 ? 'none' : 'auto',
  335. willChange: 'transform',
  336. } : undefined}
  337. onClick={isActive ? onImgClick : undefined}
  338. onPointerDown={isActive ? onImgPointerDown : undefined}
  339. onPointerMove={isActive ? onImgPointerMove : undefined}
  340. />
  341. )}
  342. </div>
  343. );
  344. })}
  345. {/* Counter — top center */}
  346. {items.length > 1 && (
  347. <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">
  348. {current + 1} / {items.length}
  349. </div>
  350. )}
  351. {/* Side arrows */}
  352. {items.length > 1 && (
  353. <>
  354. <button
  355. onClick={(e) => { e.stopPropagation(); prev(); }}
  356. 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"
  357. aria-label="Previous"
  358. >‹</button>
  359. <button
  360. onClick={(e) => { e.stopPropagation(); next(); }}
  361. 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"
  362. aria-label="Next"
  363. >›</button>
  364. </>
  365. )}
  366. {/* Close button — bottom center, ABOVE the dots */}
  367. <button
  368. onClick={onClose}
  369. 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"
  370. aria-label="Close fullscreen"
  371. >
  372. <span className="text-lg leading-none">✕</span>
  373. <span>Close</span>
  374. </button>
  375. {/* Dots — at the very bottom */}
  376. {items.length > 1 && (
  377. <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
  378. {items.map((_, i) => (
  379. <button
  380. key={i}
  381. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  382. 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'}`}
  383. aria-label={`Go to slide ${i + 1}`}
  384. />
  385. ))}
  386. </div>
  387. )}
  388. </div>
  389. );
  390. }
  391. function FlipBook({ pages, onClose }: { pages: string[]; onClose: () => void }) {
  392. const [spread, setSpread] = useState(0);
  393. const [flipping, setFlipping] = useState<'forward' | 'backward' | null>(null);
  394. const dragStartX = useRef<number | null>(null);
  395. const totalSpreads = Math.max(1, Math.ceil(pages.length / 2));
  396. const getPage = (i: number) => (i >= 0 && i < pages.length ? pages[i] : '');
  397. const leftIdx = spread * 2;
  398. const rightIdx = spread * 2 + 1;
  399. const nextLeftIdx = (spread + 1) * 2;
  400. const nextRightIdx = (spread + 1) * 2 + 1;
  401. const prevLeftIdx = (spread - 1) * 2;
  402. const prevRightIdx = (spread - 1) * 2 + 1;
  403. const goNext = useCallback(() => {
  404. if (flipping !== null || spread >= totalSpreads - 1) return;
  405. setFlipping('forward');
  406. window.setTimeout(() => { setSpread(s => s + 1); setFlipping(null); }, 700);
  407. }, [flipping, spread, totalSpreads]);
  408. const goPrev = useCallback(() => {
  409. if (flipping !== null || spread <= 0) return;
  410. setFlipping('backward');
  411. window.setTimeout(() => { setSpread(s => s - 1); setFlipping(null); }, 700);
  412. }, [flipping, spread]);
  413. useEffect(() => {
  414. const onKey = (e: KeyboardEvent) => {
  415. if (e.key === 'Escape') onClose();
  416. else if (e.key === 'ArrowRight') goNext();
  417. else if (e.key === 'ArrowLeft') goPrev();
  418. };
  419. window.addEventListener('keydown', onKey);
  420. return () => window.removeEventListener('keydown', onKey);
  421. }, [onClose, goNext, goPrev]);
  422. const onPointerDown = (e: React.PointerEvent) => { dragStartX.current = e.clientX; };
  423. const onPointerUp = (e: React.PointerEvent) => {
  424. if (dragStartX.current === null) return;
  425. const dx = e.clientX - dragStartX.current;
  426. if (Math.abs(dx) > 50) (dx < 0 ? goNext() : goPrev());
  427. dragStartX.current = null;
  428. };
  429. const onPointerCancel = () => { dragStartX.current = null; };
  430. const visibleLeft = flipping === 'backward' ? getPage(prevLeftIdx) : getPage(leftIdx);
  431. const visibleRight = flipping === 'forward' ? getPage(nextRightIdx) : getPage(rightIdx);
  432. const currentPageNum = Math.min(leftIdx + 1, pages.length);
  433. const lastPageNum = Math.min(rightIdx + 1, pages.length);
  434. const indicatorLabel = currentPageNum === lastPageNum
  435. ? `${currentPageNum} / ${pages.length}`
  436. : `${currentPageNum}-${lastPageNum} / ${pages.length}`;
  437. const pageImgClass = 'w-full h-full object-contain';
  438. return (
  439. <div
  440. className="fixed inset-0 z-50 flex items-center justify-center bg-black/95 select-none"
  441. onPointerDown={onPointerDown}
  442. onPointerUp={onPointerUp}
  443. onPointerCancel={onPointerCancel}
  444. style={{ perspective: '2500px', touchAction: 'pan-y' }}
  445. >
  446. <style dangerouslySetInnerHTML={{ __html: `
  447. @keyframes flipBookForward { from { transform: rotateY(0deg); } to { transform: rotateY(-180deg); } }
  448. @keyframes flipBookBackward { from { transform: rotateY(0deg); } to { transform: rotateY(180deg); } }
  449. `}} />
  450. <button
  451. onClick={onClose}
  452. className="absolute top-4 right-4 bg-white/10 hover:bg-white/25 text-white w-11 h-11 flex items-center justify-center rounded-full text-xl z-30 transition-colors"
  453. title="Close"
  454. aria-label="Close"
  455. >✕</button>
  456. <div
  457. className="relative shadow-2xl"
  458. style={{
  459. // Two A4 pages side-by-side: aspect ratio ≈ 1.41:1 (2 × 0.707).
  460. // Cap by 95vw and 90vh so the book never overflows.
  461. width: 'min(95vw, calc(90vh * 1.41))',
  462. aspectRatio: '1.41 / 1',
  463. maxHeight: '90vh',
  464. transformStyle: 'preserve-3d',
  465. }}
  466. >
  467. {/* Static left page */}
  468. {visibleLeft && (
  469. <div className="absolute left-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center">
  470. <img src={visibleLeft} className={pageImgClass} alt="" draggable={false} />
  471. </div>
  472. )}
  473. {/* Static right page */}
  474. {visibleRight && (
  475. <div className="absolute right-0 top-0 w-1/2 h-full bg-white overflow-hidden flex items-center justify-center">
  476. <img src={visibleRight} className={pageImgClass} alt="" draggable={false} />
  477. </div>
  478. )}
  479. {/* Flipping overlay — forward (right page rotates left) */}
  480. {flipping === 'forward' && (
  481. <div
  482. className="absolute right-0 top-0 w-1/2 h-full"
  483. style={{
  484. transformOrigin: 'left center',
  485. transformStyle: 'preserve-3d',
  486. animation: 'flipBookForward 700ms ease-in-out forwards',
  487. zIndex: 20,
  488. }}
  489. >
  490. <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}>
  491. {getPage(rightIdx) && <img src={getPage(rightIdx)} className={pageImgClass} alt="" draggable={false} />}
  492. </div>
  493. <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}>
  494. {getPage(nextLeftIdx) && <img src={getPage(nextLeftIdx)} className={pageImgClass} alt="" draggable={false} />}
  495. </div>
  496. </div>
  497. )}
  498. {/* Flipping overlay — backward (left page rotates right) */}
  499. {flipping === 'backward' && (
  500. <div
  501. className="absolute left-0 top-0 w-1/2 h-full"
  502. style={{
  503. transformOrigin: 'right center',
  504. transformStyle: 'preserve-3d',
  505. animation: 'flipBookBackward 700ms ease-in-out forwards',
  506. zIndex: 20,
  507. }}
  508. >
  509. <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden' }}>
  510. {getPage(leftIdx) && <img src={getPage(leftIdx)} className={pageImgClass} alt="" draggable={false} />}
  511. </div>
  512. <div className="absolute inset-0 bg-white overflow-hidden flex items-center justify-center" style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}>
  513. {getPage(prevRightIdx) && <img src={getPage(prevRightIdx)} className={pageImgClass} alt="" draggable={false} />}
  514. </div>
  515. </div>
  516. )}
  517. </div>
  518. <button
  519. onClick={goPrev}
  520. disabled={spread === 0 || flipping !== null}
  521. className="absolute left-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors"
  522. title="Previous page"
  523. aria-label="Previous page"
  524. >‹</button>
  525. <button
  526. onClick={goNext}
  527. disabled={spread >= totalSpreads - 1 || flipping !== null}
  528. className="absolute right-4 top-1/2 -translate-y-1/2 bg-white/10 hover:bg-white/25 disabled:opacity-25 disabled:cursor-not-allowed text-white w-14 h-14 flex items-center justify-center rounded-full text-3xl z-30 transition-colors"
  529. title="Next page"
  530. aria-label="Next page"
  531. >›</button>
  532. <div className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-white/15 text-white px-4 py-1.5 rounded-full text-sm font-medium z-30">
  533. {pages.length === 0 ? 'Nessuna pagina' : indicatorLabel}
  534. </div>
  535. </div>
  536. );
  537. }
  538. export default function PublicGrid({ cards: rawCards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  539. // Prefissa una volta tutti gli URL dei media col basePath: copre griglia, carosello,
  540. // fullscreen e flip-book, che derivano tutti da qui. Gli URL salvati restano senza prefisso.
  541. const cards = useMemo(
  542. () => rawCards.map(c => ({
  543. ...c,
  544. imageUrl: withBasePath(c.imageUrl),
  545. extraMedia: c.extraMedia?.map(m => ({ ...m, url: withBasePath(m.url) })),
  546. })),
  547. [rawCards],
  548. );
  549. const [activeCard, setActiveCard] = useState<Card | null>(null);
  550. const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);
  551. useEffect(() => {
  552. if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
  553. else document.body.style.overflow = 'unset';
  554. return () => { document.body.style.overflow = 'unset'; };
  555. }, [activeCard, fullscreenIndex]);
  556. const gridClasses: Record<number, string> = {
  557. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  558. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  559. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  560. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  561. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  562. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  563. };
  564. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  565. const carouselItems: MediaItem[] = activeCard
  566. ? [
  567. ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []),
  568. ...(activeCard.extraMedia || []),
  569. ]
  570. : [];
  571. return (
  572. <>
  573. <div className={`grid gap-4 ${activeGridClass}`}>
  574. {cards.map((card) => {
  575. const galleryCount = card.extraMedia?.length || 0;
  576. const isExternalLink = card.cardType === 'EXTERNAL_LINK';
  577. // Fall back to the first gallery item when no explicit cover is set.
  578. const previewUrl = card.imageUrl || card.extraMedia?.[0]?.url || '';
  579. const previewIsVideo = !!previewUrl && isVideoUrl(previewUrl);
  580. return (
  581. <div
  582. key={card.id}
  583. onClick={() => {
  584. // EXTERNAL_LINK + redirectOnClick: salta il modale e apri direttamente l'URL
  585. if (card.cardType === 'EXTERNAL_LINK' && card.redirectOnClick) {
  586. const url = card.actionUrl || card.shortDescription;
  587. if (url) {
  588. window.open(url, '_blank', 'noopener,noreferrer');
  589. return;
  590. }
  591. }
  592. setActiveCard(card);
  593. if (card.cardType !== 'BOOK' && card.autoFullscreen) setFullscreenIndex(0);
  594. }}
  595. 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"
  596. >
  597. {previewUrl ? (
  598. previewIsVideo ? (
  599. <video
  600. src={previewUrl}
  601. className="absolute inset-0 w-full h-full object-cover pointer-events-none"
  602. muted
  603. playsInline
  604. preload="metadata"
  605. />
  606. ) : (
  607. <img src={previewUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  608. )
  609. ) : (
  610. <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>
  611. )}
  612. {isExternalLink ? (
  613. <div
  614. className="absolute top-2 left-2 bg-black/60 text-white text-xs font-bold px-2 py-0.5 rounded-full flex items-center justify-center z-10 leading-none"
  615. title="External Link"
  616. aria-label="External Link"
  617. >
  618. L
  619. </div>
  620. ) : galleryCount > 0 ? (
  621. <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">
  622. <span>⊞</span>
  623. <span>{galleryCount}</span>
  624. </div>
  625. ) : null}
  626. <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">
  627. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  628. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  629. {card.shortDescription}
  630. </p>
  631. </div>
  632. </div>
  633. );
  634. })}
  635. </div>
  636. {activeCard && activeCard.cardType === 'BOOK' && (
  637. <FlipBook
  638. pages={(activeCard.extraMedia || []).map(m => m.url)}
  639. onClose={() => setActiveCard(null)}
  640. />
  641. )}
  642. {activeCard && activeCard.cardType !== 'BOOK' && fullscreenIndex === null && (
  643. <div
  644. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  645. onClick={() => setActiveCard(null)}
  646. >
  647. <div
  648. 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"
  649. onClick={(e) => e.stopPropagation()}
  650. >
  651. <div className="relative">
  652. <MediaCarousel
  653. items={carouselItems}
  654. onMediaClick={(i) => setFullscreenIndex(i)}
  655. />
  656. <button
  657. onClick={() => setActiveCard(null)}
  658. 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"
  659. title="Close"
  660. >✕</button>
  661. </div>
  662. {(activeCard.title || activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) && (
  663. <div className="p-8">
  664. {activeCard.title && (
  665. <h2 className={`text-3xl font-bold text-gray-900 ${(activeCard.shortDescription || activeCard.fullContent || activeCard.actionUrl) ? 'mb-4' : ''}`}>
  666. {activeCard.title}
  667. </h2>
  668. )}
  669. {activeCard.cardType === 'EXTERNAL_LINK' && (activeCard.actionUrl || activeCard.shortDescription) ? (
  670. <a
  671. href={activeCard.actionUrl || activeCard.shortDescription}
  672. target="_blank"
  673. rel="noopener noreferrer"
  674. className="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 underline break-all text-lg font-medium"
  675. >
  676. <span>{activeCard.shortDescription || activeCard.actionUrl}</span>
  677. <span aria-hidden>↗</span>
  678. </a>
  679. ) : activeCard.fullContent ? (
  680. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  681. ) : activeCard.shortDescription ? (
  682. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  683. ) : null}
  684. </div>
  685. )}
  686. </div>
  687. </div>
  688. )}
  689. {fullscreenIndex !== null && activeCard && (
  690. <FullscreenViewer
  691. items={carouselItems}
  692. startIndex={fullscreenIndex}
  693. onClose={() => {
  694. setFullscreenIndex(null);
  695. setActiveCard(null);
  696. }}
  697. />
  698. )}
  699. </>
  700. );
  701. }