Nelze vybrat více než 25 témat Téma musí začínat písmenem nebo číslem, může obsahovat pomlčky („-“) a může být dlouhé až 35 znaků.
 
 
 

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