Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.
 
 
 

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