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

366 строки
14 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 touchStartX = useRef<number | null>(null);
  14. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  15. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  16. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  17. useEffect(() => {
  18. const onKey = (e: KeyboardEvent) => {
  19. if (e.key === 'ArrowLeft') prev();
  20. if (e.key === 'ArrowRight') next();
  21. };
  22. window.addEventListener('keydown', onKey);
  23. return () => window.removeEventListener('keydown', onKey);
  24. }, [prev, next]);
  25. useEffect(() => { setCurrent(0); }, [items]);
  26. // Pause non-current videos; autoplay current if flagged
  27. useEffect(() => {
  28. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  29. if (!vid) return;
  30. const idx = parseInt(key, 10);
  31. if (idx !== current) { vid.pause(); return; }
  32. const item = items[idx];
  33. if (item && isVideoUrl(item.url) && item.autoplay) {
  34. vid.muted = true;
  35. vid.play().catch(() => {});
  36. }
  37. });
  38. }, [current, items]);
  39. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  40. const onTouchEnd = (e: React.TouchEvent) => {
  41. if (touchStartX.current === null) return;
  42. const delta = e.changedTouches[0].clientX - touchStartX.current;
  43. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  44. touchStartX.current = null;
  45. };
  46. if (items.length === 0) {
  47. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  48. }
  49. return (
  50. <div
  51. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  52. onTouchStart={onTouchStart}
  53. onTouchEnd={onTouchEnd}
  54. >
  55. {items.map((item, i) => {
  56. const isActive = i === current;
  57. const video = isVideoUrl(item.url);
  58. return (
  59. <div
  60. key={item.url + i}
  61. className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  62. >
  63. {video ? (
  64. <>
  65. <video
  66. ref={el => { videoRefs.current[i] = el; }}
  67. src={item.url}
  68. className="w-full h-full object-contain bg-black"
  69. controls
  70. controlsList="nofullscreen"
  71. disablePictureInPicture
  72. playsInline
  73. muted={!!item.autoplay}
  74. preload="metadata"
  75. />
  76. <button
  77. onClick={(e) => { e.stopPropagation(); onMediaClick?.(i); }}
  78. className="absolute top-3 left-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"
  79. title="Expand fullscreen"
  80. aria-label="Expand fullscreen"
  81. >
  82. <svg viewBox="0 0 24 24" className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
  83. <path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5" />
  84. </svg>
  85. </button>
  86. </>
  87. ) : (
  88. <img
  89. src={item.url}
  90. alt=""
  91. className="w-full h-full object-cover cursor-zoom-in"
  92. onClick={() => onMediaClick?.(i)}
  93. title="Click to view fullscreen"
  94. />
  95. )}
  96. </div>
  97. );
  98. })}
  99. {items.length > 1 && (
  100. <>
  101. <button
  102. onClick={(e) => { e.stopPropagation(); prev(); }}
  103. 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"
  104. aria-label="Previous"
  105. >‹</button>
  106. <button
  107. onClick={(e) => { e.stopPropagation(); next(); }}
  108. 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"
  109. aria-label="Next"
  110. >›</button>
  111. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  112. {items.map((_, i) => (
  113. <button
  114. key={i}
  115. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  116. 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'}`}
  117. aria-label={`Go to slide ${i + 1}`}
  118. />
  119. ))}
  120. </div>
  121. <div className="absolute top-3 right-3 bg-black/50 text-white text-xs font-semibold px-2 py-0.5 rounded-full z-10">
  122. {current + 1} / {items.length}
  123. </div>
  124. </>
  125. )}
  126. </div>
  127. );
  128. }
  129. function FullscreenViewer({
  130. items,
  131. startIndex,
  132. onClose,
  133. }: {
  134. items: MediaItem[];
  135. startIndex: number;
  136. onClose: () => void;
  137. }) {
  138. const [current, setCurrent] = useState(startIndex);
  139. const touchStartX = useRef<number | null>(null);
  140. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  141. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  142. useEffect(() => {
  143. const onKey = (e: KeyboardEvent) => {
  144. if (e.key === 'Escape') onClose();
  145. else if (e.key === 'ArrowLeft') prev();
  146. else if (e.key === 'ArrowRight') next();
  147. };
  148. window.addEventListener('keydown', onKey);
  149. return () => window.removeEventListener('keydown', onKey);
  150. }, [prev, next, onClose]);
  151. const onTouchStart = (e: React.TouchEvent) => { touchStartX.current = e.touches[0].clientX; };
  152. const onTouchEnd = (e: React.TouchEvent) => {
  153. if (touchStartX.current === null) return;
  154. const delta = e.changedTouches[0].clientX - touchStartX.current;
  155. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  156. touchStartX.current = null;
  157. };
  158. return (
  159. <div
  160. className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center select-none animate-in fade-in duration-200"
  161. onTouchStart={onTouchStart}
  162. onTouchEnd={onTouchEnd}
  163. >
  164. {/* Media — full resolution, contained */}
  165. {items.map((item, i) => {
  166. const isActive = i === current;
  167. const video = isVideoUrl(item.url);
  168. return (
  169. <div
  170. key={item.url + i}
  171. 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'}`}
  172. >
  173. {video ? (
  174. <video
  175. src={item.url}
  176. className="max-w-full max-h-full"
  177. controls
  178. controlsList="nofullscreen"
  179. disablePictureInPicture
  180. playsInline
  181. autoPlay={!!item.autoplay}
  182. muted={!!item.autoplay}
  183. />
  184. ) : (
  185. <img
  186. src={item.url}
  187. alt=""
  188. className="max-w-full max-h-full object-contain"
  189. />
  190. )}
  191. </div>
  192. );
  193. })}
  194. {/* Counter — top center */}
  195. {items.length > 1 && (
  196. <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">
  197. {current + 1} / {items.length}
  198. </div>
  199. )}
  200. {/* Side arrows */}
  201. {items.length > 1 && (
  202. <>
  203. <button
  204. onClick={(e) => { e.stopPropagation(); prev(); }}
  205. 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"
  206. aria-label="Previous"
  207. >‹</button>
  208. <button
  209. onClick={(e) => { e.stopPropagation(); next(); }}
  210. 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"
  211. aria-label="Next"
  212. >›</button>
  213. </>
  214. )}
  215. {/* Close button — bottom center, ABOVE the dots */}
  216. <button
  217. onClick={onClose}
  218. 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"
  219. aria-label="Close fullscreen"
  220. >
  221. <span className="text-lg leading-none">✕</span>
  222. <span>Close</span>
  223. </button>
  224. {/* Dots — at the very bottom */}
  225. {items.length > 1 && (
  226. <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-20">
  227. {items.map((_, i) => (
  228. <button
  229. key={i}
  230. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  231. 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'}`}
  232. aria-label={`Go to slide ${i + 1}`}
  233. />
  234. ))}
  235. </div>
  236. )}
  237. </div>
  238. );
  239. }
  240. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  241. const [activeCard, setActiveCard] = useState<Card | null>(null);
  242. const [fullscreenIndex, setFullscreenIndex] = useState<number | null>(null);
  243. useEffect(() => {
  244. if (activeCard || fullscreenIndex !== null) document.body.style.overflow = 'hidden';
  245. else document.body.style.overflow = 'unset';
  246. return () => { document.body.style.overflow = 'unset'; };
  247. }, [activeCard, fullscreenIndex]);
  248. const gridClasses: Record<number, string> = {
  249. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  250. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  251. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  252. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  253. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  254. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  255. };
  256. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  257. const carouselItems: MediaItem[] = activeCard
  258. ? [
  259. ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []),
  260. ...(activeCard.extraMedia || []),
  261. ]
  262. : [];
  263. return (
  264. <>
  265. <div className={`grid gap-4 ${activeGridClass}`}>
  266. {cards.map((card) => {
  267. const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
  268. return (
  269. <div
  270. key={card.id}
  271. onClick={() => setActiveCard(card)}
  272. 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"
  273. >
  274. {card.imageUrl ? (
  275. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  276. ) : (
  277. <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>
  278. )}
  279. {galleryCount > 1 && (
  280. <div className="absolute top-2 right-2 bg-black/60 text-white text-xs font-semibold px-1.5 py-0.5 rounded-full flex items-center gap-1">
  281. <span>⊞</span>
  282. <span>{galleryCount}</span>
  283. </div>
  284. )}
  285. <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">
  286. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  287. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  288. {card.shortDescription}
  289. </p>
  290. </div>
  291. </div>
  292. );
  293. })}
  294. </div>
  295. {activeCard && (
  296. <div
  297. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  298. onClick={() => setActiveCard(null)}
  299. >
  300. <div
  301. 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"
  302. onClick={(e) => e.stopPropagation()}
  303. >
  304. <div className="relative">
  305. <MediaCarousel
  306. items={carouselItems}
  307. onMediaClick={(i) => setFullscreenIndex(i)}
  308. />
  309. <button
  310. onClick={() => setActiveCard(null)}
  311. 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"
  312. title="Close"
  313. >✕</button>
  314. </div>
  315. <div className="p-8">
  316. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  317. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  318. {activeCard.fullContent ? (
  319. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  320. ) : (
  321. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  322. )}
  323. </div>
  324. </div>
  325. </div>
  326. )}
  327. {fullscreenIndex !== null && activeCard && (
  328. <FullscreenViewer
  329. items={carouselItems}
  330. startIndex={fullscreenIndex}
  331. onClose={() => setFullscreenIndex(null)}
  332. />
  333. )}
  334. </>
  335. );
  336. }