Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 

824 lignes
34 KiB

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