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

184 рядки
7.5 KiB

  1. 'use client';
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { Card } from '@/types';
  4. function ImageCarousel({ images }: { images: string[] }) {
  5. const [current, setCurrent] = useState(0);
  6. const touchStartX = useRef<number | null>(null);
  7. const prev = useCallback(() => setCurrent(i => (i - 1 + images.length) % images.length), [images.length]);
  8. const next = useCallback(() => setCurrent(i => (i + 1) % images.length), [images.length]);
  9. useEffect(() => {
  10. const onKey = (e: KeyboardEvent) => {
  11. if (e.key === 'ArrowLeft') prev();
  12. if (e.key === 'ArrowRight') next();
  13. };
  14. window.addEventListener('keydown', onKey);
  15. return () => window.removeEventListener('keydown', onKey);
  16. }, [prev, next]);
  17. // Reset to first image when a new card is shown
  18. useEffect(() => { setCurrent(0); }, [images]);
  19. const onTouchStart = (e: React.TouchEvent) => {
  20. touchStartX.current = e.touches[0].clientX;
  21. };
  22. const onTouchEnd = (e: React.TouchEvent) => {
  23. if (touchStartX.current === null) return;
  24. const delta = e.changedTouches[0].clientX - touchStartX.current;
  25. if (Math.abs(delta) > 50) delta < 0 ? next() : prev();
  26. touchStartX.current = null;
  27. };
  28. if (images.length === 0) {
  29. return <div className="h-72 w-full bg-gray-100 flex items-center justify-center text-gray-400 rounded-t-2xl">No Image</div>;
  30. }
  31. return (
  32. <div
  33. className="relative h-72 w-full bg-black overflow-hidden rounded-t-2xl select-none"
  34. onTouchStart={onTouchStart}
  35. onTouchEnd={onTouchEnd}
  36. >
  37. {/* Images */}
  38. {images.map((src, i) => (
  39. <img
  40. key={src}
  41. src={src}
  42. alt=""
  43. className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${i === current ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
  44. />
  45. ))}
  46. {/* Arrows — only if more than one image */}
  47. {images.length > 1 && (
  48. <>
  49. <button
  50. onClick={(e) => { e.stopPropagation(); prev(); }}
  51. 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"
  52. aria-label="Previous image"
  53. >
  54. </button>
  55. <button
  56. onClick={(e) => { e.stopPropagation(); next(); }}
  57. 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"
  58. aria-label="Next image"
  59. >
  60. </button>
  61. {/* Dot indicators */}
  62. <div className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 z-10">
  63. {images.map((_, i) => (
  64. <button
  65. key={i}
  66. onClick={(e) => { e.stopPropagation(); setCurrent(i); }}
  67. 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'}`}
  68. aria-label={`Go to image ${i + 1}`}
  69. />
  70. ))}
  71. </div>
  72. {/* Counter badge */}
  73. <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">
  74. {current + 1} / {images.length}
  75. </div>
  76. </>
  77. )}
  78. </div>
  79. );
  80. }
  81. export default function PublicGrid({ cards, maxCols = 5 }: { cards: Card[], maxCols?: number }) {
  82. const [activeCard, setActiveCard] = useState<Card | null>(null);
  83. useEffect(() => {
  84. if (activeCard) document.body.style.overflow = 'hidden';
  85. else document.body.style.overflow = 'unset';
  86. return () => { document.body.style.overflow = 'unset'; };
  87. }, [activeCard]);
  88. const gridClasses: Record<number, string> = {
  89. 3: 'xl:grid-cols-3 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  90. 4: 'xl:grid-cols-4 lg:grid-cols-3 md:grid-cols-2 grid-cols-1',
  91. 5: 'xl:grid-cols-5 lg:grid-cols-4 md:grid-cols-3 grid-cols-1',
  92. 6: 'xl:grid-cols-6 lg:grid-cols-4 md:grid-cols-3 grid-cols-2',
  93. 7: 'xl:grid-cols-7 lg:grid-cols-5 md:grid-cols-4 grid-cols-2',
  94. 8: 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-4 grid-cols-2',
  95. };
  96. const activeGridClass = gridClasses[maxCols] || gridClasses[5];
  97. return (
  98. <>
  99. <div className={`grid gap-4 ${activeGridClass}`}>
  100. {cards.map((card) => (
  101. <div
  102. key={card.id}
  103. onClick={() => setActiveCard(card)}
  104. 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"
  105. >
  106. {card.imageUrl ? (
  107. <img src={card.imageUrl} alt={card.title} className="absolute inset-0 w-full h-full object-cover" />
  108. ) : (
  109. <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>
  110. )}
  111. {/* Gallery badge */}
  112. {card.extraImages && card.extraImages.length > 0 && (
  113. <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">
  114. <span>⊞</span>
  115. <span>{1 + card.extraImages.length}</span>
  116. </div>
  117. )}
  118. <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">
  119. <h3 className="text-xl font-bold drop-shadow-md">{card.title}</h3>
  120. <p className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300 line-clamp-2 mt-1 text-gray-200 drop-shadow">
  121. {card.shortDescription}
  122. </p>
  123. </div>
  124. </div>
  125. ))}
  126. </div>
  127. {activeCard && (
  128. <div
  129. className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-md p-4"
  130. onClick={() => setActiveCard(null)}
  131. >
  132. <div
  133. 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"
  134. onClick={(e) => e.stopPropagation()}
  135. >
  136. <div className="relative">
  137. <ImageCarousel
  138. images={[
  139. ...(activeCard.imageUrl ? [activeCard.imageUrl] : []),
  140. ...(activeCard.extraImages || []),
  141. ]}
  142. />
  143. <button
  144. onClick={() => setActiveCard(null)}
  145. 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"
  146. title="Close"
  147. >
  148. </button>
  149. </div>
  150. <div className="p-8">
  151. <div className="text-xs text-blue-600 font-bold tracking-wider uppercase mb-2">{activeCard.cardType.replace('_', ' ')}</div>
  152. <h2 className="text-3xl font-bold mb-4 text-gray-900">{activeCard.title}</h2>
  153. {activeCard.fullContent ? (
  154. <div className="prose max-w-none text-gray-700" dangerouslySetInnerHTML={{ __html: activeCard.fullContent }} />
  155. ) : (
  156. <p className="text-gray-500 italic text-lg">{activeCard.shortDescription}</p>
  157. )}
  158. </div>
  159. </div>
  160. </div>
  161. )}
  162. </>
  163. );
  164. }