Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 

456 рядки
18 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 touchStartX = useRef<number | null>(null);
  173. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  174. const markPlaying = (i: number) => setPlaying(p => { const n = new Set(p); n.add(i); return n; });
  175. const markPaused = (i: number) => setPlaying(p => { const n = new Set(p); n.delete(i); return n; });
  176. const applyVolume = (v: HTMLVideoElement, item: MediaItem) => {
  177. v.muted = !!item.muted;
  178. };
  179. const togglePlay = (i: number) => {
  180. const v = videoRefs.current[i];
  181. if (!v) return;
  182. if (v.paused) {
  183. const item = items[i];
  184. if (item) applyVolume(v, item);
  185. v.play().catch(() => {});
  186. } else {
  187. v.pause();
  188. }
  189. };
  190. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  191. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  192. useEffect(() => {
  193. const onKey = (e: KeyboardEvent) => {
  194. if (e.key === 'Escape') onClose();
  195. else if (e.key === 'ArrowLeft') prev();
  196. else if (e.key === 'ArrowRight') next();
  197. };
  198. window.addEventListener('keydown', onKey);
  199. return () => window.removeEventListener('keydown', onKey);
  200. }, [prev, next, onClose]);
  201. // Pause non-current videos in fullscreen
  202. useEffect(() => {
  203. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  204. if (!vid) return;
  205. const idx = parseInt(key, 10);
  206. if (idx !== current) { vid.pause(); return; }
  207. const item = items[idx];
  208. if (item && isVideoUrl(item.url) && item.autoplay) {
  209. applyVolume(vid, item);
  210. vid.play().catch(() => {
  211. vid.muted = true;
  212. vid.play().catch(() => {});
  213. });
  214. }
  215. });
  216. }, [current, items]);
  217. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  218. const onTouchEnd = (e: React.TouchEvent) => {
  219. if (touchStartX.current === null) return;
  220. const delta = e.changedTouches[0].clientX - touchStartX.current;
  221. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  222. touchStartX.current = null;
  223. };
  224. return (
  225. <div
  226. className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
  227. onTouchStart={onTouchStart}
  228. onTouchEnd={onTouchEnd}
  229. >
  230. {/* Media — full resolution, contained */}
  231. {items.map((item, i) => {
  232. const isActive = i === current;
  233. const video = isVideoUrl(item.url);
  234. return (
  235. <div
  236. key={item.url + i}
  237. 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'}`}
  238. >
  239. {video ? (
  240. <div
  241. className="relative flex items-center justify-center max-w-full max-h-full cursor-pointer"
  242. onClick={(e) => { e.stopPropagation(); togglePlay(i); }}
  243. >
  244. <video
  245. ref={el => { videoRefs.current[i] = el; }}
  246. src={item.url}
  247. className="max-w-full max-h-full pointer-events-none"
  248. playsInline
  249. preload="metadata"
  250. onLoadedMetadata={(e) => applyVolume(e.currentTarget, item)}
  251. onPlay={() => markPlaying(i)}
  252. onPause={() => markPaused(i)}
  253. onEnded={() => markPaused(i)}
  254. />
  255. {!playing.has(i) && (
  256. <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
  257. <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>
  258. </div>
  259. )}
  260. </div>
  261. ) : (
  262. <img
  263. src={item.url}
  264. alt=""
  265. className="max-w-full max-h-full object-contain"
  266. />
  267. )}
  268. </div>
  269. );
  270. })}
  271. {/* Counter — top center */}
  272. {items.length > 1 && (
  273. <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">
  274. {current + 1} / {items.length}
  275. </div>
  276. )}
  277. {/* Side arrows */}
  278. {items.length > 1 && (
  279. <>
  280. <button
  281. onClick={(e) => { e.stopPropagation(); prev(); }}
  282. 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"
  283. aria-label="Previous"
  284. >‹</button>
  285. <button
  286. onClick={(e) => { e.stopPropagation(); next(); }}
  287. 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"
  288. aria-label="Next"
  289. >›</button>
  290. </>
  291. )}
  292. {/* Close button — bottom center, ABOVE the dots */}
  293. <button
  294. onClick={onClose}
  295. 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"
  296. aria-label="Close fullscreen"
  297. >
  298. <span className="text-lg leading-none">✕</span>
  299. <span>Close</span>
  300. </button>
  301. {/* Dots — at the very bottom */}
  302. {items.length > 1 && (
  303. <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
  304. {items.map((_, i) => (
  305. <button
  306. key={i}
  307. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  308. 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'}`}
  309. aria-label={`Go to slide ${i + 1}`}
  310. />
  311. ))}
  312. </div>
  313. )}
  314. </div>
  315. );
  316. }
  317. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  318. const [activeCard, setActiveCard] = useState<Card | null>(null);
  319. const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);
  320. useEffect(() => {
  321. if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
  322. else document.body.style.overflow = 'unset';
  323. return () => { document.body.style.overflow = 'unset'; };
  324. }, [activeCard, fullscreenIndex]);
  325. const gridClasses: Record<number, string> = {
  326. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  327. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  328. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  329. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  330. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  331. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  332. };
  333. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  334. const carouselItems: MediaItem[] = activeCard
  335. ? [
  336. ...(activeCard.imageUrl && !activeCard.skipPreview ? [{ url: activeCard.imageUrl }] : []),
  337. ...(activeCard.extraMedia || []),
  338. ]
  339. : [];
  340. return (
  341. <>
  342. <div className={`grid gap-4 ${activeGridClass}`}>
  343. {cards.map((card) => {
  344. const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
  345. return (
  346. <div
  347. key={card.id}
  348. onClick={() => {
  349. setActiveCard(card);
  350. if (card.autoFullscreen) setFullscreenIndex(0);
  351. }}
  352. 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"
  353. >
  354. {card.imageUrl ? (
  355. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  356. ) : (
  357. <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>
  358. )}
  359. {galleryCount > 1 && (
  360. <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">
  361. <span>⊞</span>
  362. <span>{galleryCount}</span>
  363. </div>
  364. )}
  365. <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">
  366. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  367. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  368. {card.shortDescription}
  369. </p>
  370. </div>
  371. </div>
  372. );
  373. })}
  374. </div>
  375. {activeCard && (
  376. <div
  377. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  378. onClick={() => setActiveCard(null)}
  379. >
  380. <div
  381. 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"
  382. onClick={(e) => e.stopPropagation()}
  383. >
  384. <div className="relative">
  385. <MediaCarousel
  386. items={carouselItems}
  387. onMediaClick={(i) => setFullscreenIndex(i)}
  388. />
  389. <button
  390. onClick={() => setActiveCard(null)}
  391. 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"
  392. title="Close"
  393. >✕</button>
  394. </div>
  395. <div className="p-8">
  396. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  397. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  398. {activeCard.fullContent ? (
  399. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  400. ) : (
  401. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  402. )}
  403. </div>
  404. </div>
  405. </div>
  406. )}
  407. {fullscreenIndex !== null && activeCard && (
  408. <FullscreenViewer
  409. items={carouselItems}
  410. startIndex={fullscreenIndex}
  411. onClose={() => {
  412. setFullscreenIndex(null);
  413. setActiveCard(null);
  414. }}
  415. />
  416. )}
  417. </>
  418. );
  419. }