You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

211 line
8.5 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({ items }: { items: MediaItem[] }) {
  6. const [current, setCurrent] = useState(0);
  7. const touchStartX = useRef<number | null>(null);
  8. const videoRefs = useRef<Record<number, HTMLVideoElement | null>>({});
  9. const prev = useCallback(() => setCurrent(i => (i - 1 + items.length) % items.length), [items.length]);
  10. const next = useCallback(() => setCurrent(i => (i + 1) % items.length), [items.length]);
  11. useEffect(() => {
  12. const onKey = (e: KeyboardEvent) => {
  13. if (e.key === 'ArrowLeft') prev();
  14. if (e.key === 'ArrowRight') next();
  15. };
  16. window.addEventListener('keydown', onKey);
  17. return () => window.removeEventListener('keydown', onKey);
  18. }, [prev, next]);
  19. useEffect(() => { setCurrent(0); }, [items]);
  20. // Pause all videos that aren't current; autoplay current if flagged
  21. useEffect(() => {
  22. Object.entries(videoRefs.current).forEach(([key, vid]) => {
  23. if (!vid) return;
  24. const idx = parseInt(key, 10);
  25. if (idx !== current) {
  26. vid.pause();
  27. return;
  28. }
  29. const item = items[idx];
  30. if (item && isVideoUrl(item.url) && item.autoplay) {
  31. vid.muted = true;
  32. vid.play().catch(() => {/* autoplay blocked, ignore */});
  33. }
  34. });
  35. }, [current, items]);
  36. const onTouchStart = (e: React.TouchEvent) => {
  37. touchStartX.current = e.touches[0].clientX;
  38. };
  39. const onTouchEnd = (e: React.TouchEvent) => {
  40. if (touchStartX.current === null) return;
  41. const delta = e.changedTouches[0].clientX - touchStartX.current;
  42. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  43. touchStartX.current = null;
  44. };
  45. if (items.length === 0) {
  46. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  47. }
  48. return (
  49. <div
  50. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  51. onTouchStart={onTouchStart}
  52. onTouchEnd={onTouchEnd}
  53. >
  54. {items.map((item, i) => {
  55. const isActive = i === current;
  56. const video = isVideoUrl(item.url);
  57. return (
  58. <div
  59. key={item.url + i}
  60. className={`absolute inset-0 transition-opacity duration-300 ${isActive ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  61. >
  62. {video ? (
  63. <video
  64. ref={el => { videoRefs.current[i] = el; }}
  65. src={item.url}
  66. className="w-full h-full object-contain bg-black"
  67. controls
  68. playsInline
  69. muted={!!item.autoplay}
  70. preload="metadata"
  71. />
  72. ) : (
  73. <img src={item.url} alt="" className="w-full h-full object-cover" />
  74. )}
  75. </div>
  76. );
  77. })}
  78. {items.length > 1 && (
  79. <>
  80. <button
  81. onClick={(e) => { e.stopPropagation(); prev(); }}
  82. 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"
  83. aria-label="Previous"
  84. >‹</button>
  85. <button
  86. onClick={(e) => { e.stopPropagation(); next(); }}
  87. 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"
  88. aria-label="Next"
  89. >›</button>
  90. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  91. {items.map((_, i) => (
  92. <button
  93. key={i}
  94. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  95. 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'}`}
  96. aria-label={`Go to slide ${i + 1}`}
  97. />
  98. ))}
  99. </div>
  100. <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">
  101. {current + 1} / {items.length}
  102. </div>
  103. </>
  104. )}
  105. </div>
  106. );
  107. }
  108. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  109. const [activeCard, setActiveCard] = useState<Card | null>(null);
  110. useEffect(() => {
  111. if (activeCard) document.body.style.overflow = 'hidden';
  112. else document.body.style.overflow = 'unset';
  113. return () => { document.body.style.overflow = 'unset'; };
  114. }, [activeCard]);
  115. const gridClasses: Record<number, string> = {
  116. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  117. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  118. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  119. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  120. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  121. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  122. };
  123. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  124. return (
  125. <>
  126. <div className={`grid gap-4 ${activeGridClass}`}>
  127. {cards.map((card) => {
  128. const galleryCount = (card.extraMedia?.length || 0) + (card.imageUrl ? 1 : 0);
  129. return (
  130. <div
  131. key={card.id}
  132. onClick={() => setActiveCard(card)}
  133. 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"
  134. >
  135. {card.imageUrl ? (
  136. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  137. ) : (
  138. <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>
  139. )}
  140. {galleryCount > 1 && (
  141. <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">
  142. <span>⊞</span>
  143. <span>{galleryCount}</span>
  144. </div>
  145. )}
  146. <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">
  147. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  148. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  149. {card.shortDescription}
  150. </p>
  151. </div>
  152. </div>
  153. );
  154. })}
  155. </div>
  156. {activeCard && (
  157. <div
  158. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  159. onClick={() => setActiveCard(null)}
  160. >
  161. <div
  162. 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"
  163. onClick={(e) => e.stopPropagation()}
  164. >
  165. <div className="relative">
  166. <MediaCarousel
  167. items={[
  168. ...(activeCard.imageUrl ? [{ url: activeCard.imageUrl }] : []),
  169. ...(activeCard.extraMedia || []),
  170. ]}
  171. />
  172. <button
  173. onClick={() => setActiveCard(null)}
  174. 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"
  175. title="Close"
  176. >✕</button>
  177. </div>
  178. <div className="p-8">
  179. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  180. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  181. {activeCard.fullContent ? (
  182. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  183. ) : (
  184. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  185. )}
  186. </div>
  187. </div>
  188. </div>
  189. )}
  190. </>
  191. );
  192. }