@devvistatech/devvista-kit 0.0.9 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (306) hide show
  1. package/CHANGELOG.md +12 -12
  2. package/LICENSE +6 -6
  3. package/README.md +15 -15
  4. package/app/about/page.tsx +298 -298
  5. package/app/adRequest/page.tsx +549 -549
  6. package/app/analytics/page.tsx +346 -346
  7. package/app/api/about/route.ts +306 -306
  8. package/app/api/adRequest/route.ts +567 -567
  9. package/app/api/analytics/[reportType]/route.ts +337 -337
  10. package/app/api/bio/route.ts +313 -313
  11. package/app/api/blog/route.ts +306 -306
  12. package/app/api/chat/route.ts +14 -14
  13. package/app/api/contact/route.ts +409 -409
  14. package/app/api/contacts/route.ts +224 -224
  15. package/app/api/files/route.ts +429 -429
  16. package/app/api/gallery-data/route.ts +735 -735
  17. package/app/api/schedule/route.ts +455 -455
  18. package/app/api/sync-user/route.ts +131 -131
  19. package/app/api/trial-request/route.ts +297 -297
  20. package/app/blog/[id]/page.tsx +288 -288
  21. package/app/blog/page.tsx +216 -216
  22. package/app/contact/page.tsx +284 -284
  23. package/app/faq/page.tsx +191 -191
  24. package/app/gallery/page.tsx +315 -315
  25. package/app/globals.css +58 -58
  26. package/app/layout.tsx +110 -110
  27. package/app/not-found.tsx +20 -20
  28. package/app/page.tsx +338 -338
  29. package/app/schedule/page.tsx +660 -660
  30. package/bin/init.js +219 -219
  31. package/components/addOns/functional/BioEditor.tsx +446 -446
  32. package/components/addOns/functional/CalendlyWidget.tsx +107 -107
  33. package/components/addOns/functional/ClassList.tsx +145 -145
  34. package/components/addOns/functional/ClassPopup.tsx +398 -398
  35. package/components/addOns/functional/ContactForm.tsx +284 -284
  36. package/components/addOns/functional/FileUploader.tsx +294 -294
  37. package/components/addOns/functional/ImageDescCarousel.tsx +730 -730
  38. package/components/addOns/functional/NewUserAnalytics.tsx +100 -100
  39. package/components/addOns/functional/ScheduleCarousel.tsx +171 -171
  40. package/components/addOns/functional/aboutSections/AboutSection.tsx +544 -544
  41. package/components/addOns/functional/aboutSections/constants/aboutSection.ts +65 -65
  42. package/components/addOns/functional/blogSections/BlogDashboard.tsx +184 -184
  43. package/components/addOns/functional/blogSections/BlogFormPopUp.tsx +554 -554
  44. package/components/addOns/functional/blogSections/BlogList.tsx +148 -148
  45. package/components/addOns/functional/blogSections/BlogSidebar.tsx +58 -58
  46. package/components/addOns/functional/blogSections/constants/blogDashboard.ts +28 -28
  47. package/components/addOns/functional/blogSections/constants/blogFormPopUp.ts +97 -97
  48. package/components/addOns/functional/blogSections/constants/blogList.ts +22 -22
  49. package/components/addOns/functional/blogSections/constants/blogSidebar.ts +15 -15
  50. package/components/addOns/functional/contactsDashboard/ContactsDashboard.tsx +366 -366
  51. package/components/addOns/functional/contactsDashboard/constants/contactsDashboard.ts +70 -70
  52. package/components/addOns/functional/galleries/GalleryComplex.tsx +836 -836
  53. package/components/addOns/functional/galleries/GallerySimple.tsx +509 -509
  54. package/components/addOns/functional/galleries/constants/galleryComplex.ts +106 -106
  55. package/components/addOns/functional/galleries/constants/gallerySimple.ts +76 -76
  56. package/components/addOns/functional/schedules/ScheduleGridOne.tsx +262 -262
  57. package/components/addOns/functional/schedules/ScheduleGridTwo.tsx +294 -294
  58. package/components/addOns/functional/schedules/ScheduleGridTwoBasic.tsx +288 -288
  59. package/components/addOns/functional/schedules/SchedulerForm.tsx +428 -428
  60. package/components/addOns/functional/schedules/constants/ScheduleGridTwo.ts +40 -40
  61. package/components/addOns/functional/schedules/constants/ScheduleGridTwoBasic.ts +40 -40
  62. package/components/addOns/functional/schedules/constants/SchedulerForm.ts +65 -65
  63. package/components/addOns/functional/schedules/constants/scheduleGridOne.ts +54 -54
  64. package/components/addOns/non-functional/AnnouncementBanner.tsx +46 -46
  65. package/components/addOns/non-functional/FeaturesSection.tsx +62 -62
  66. package/components/addOns/non-functional/Heros/HeroSection.tsx +142 -142
  67. package/components/addOns/non-functional/IconBubble.tsx +49 -49
  68. package/components/addOns/non-functional/SampleCarousel.tsx +204 -204
  69. package/components/addOns/non-functional/Testimonials.tsx +334 -334
  70. package/components/addOns/non-functional/ThreeSetGallery.tsx +63 -63
  71. package/components/addOns/non-functional/aboutSections/AboutSection.tsx +62 -62
  72. package/components/addOns/non-functional/aboutSections/constants/aboutSection.ts +24 -24
  73. package/components/addOns/non-functional/imageCarousels/ProductSlider.tsx +117 -117
  74. package/components/addOns/non-functional/imageCarousels/ProgramCarousel.tsx +232 -232
  75. package/components/addOns/non-functional/imageCarousels/constants/programCarousel.ts +39 -39
  76. package/components/addOns/non-functional/imageCarousels/constants/programSlider.ts +36 -36
  77. package/components/addOns/non-functional/spinner.tsx +21 -21
  78. package/components/footers/footer.tsx +453 -453
  79. package/components/navBars/navbar.tsx +310 -310
  80. package/components/other/accordion.tsx +58 -58
  81. package/components/other/admin-menu.tsx +68 -68
  82. package/components/other/alert-dialog.tsx +141 -141
  83. package/components/other/alert.tsx +59 -59
  84. package/components/other/aspect-ratio.tsx +7 -7
  85. package/components/other/avatar.tsx +50 -50
  86. package/components/other/badge.tsx +36 -36
  87. package/components/other/breadcrumb.tsx +115 -115
  88. package/components/other/button.tsx +738 -738
  89. package/components/other/calendar.tsx +66 -66
  90. package/components/other/card.tsx +86 -86
  91. package/components/other/carousel.tsx +274 -274
  92. package/components/other/chart.tsx +363 -363
  93. package/components/other/checkbox.tsx +30 -30
  94. package/components/other/collapsible.tsx +11 -11
  95. package/components/other/command.tsx +155 -155
  96. package/components/other/context-menu.tsx +200 -200
  97. package/components/other/dialog.tsx +122 -122
  98. package/components/other/drawer.tsx +118 -118
  99. package/components/other/dropdown-menu.tsx +200 -200
  100. package/components/other/form.tsx +179 -179
  101. package/components/other/hover-card.tsx +29 -29
  102. package/components/other/input-otp.tsx +71 -71
  103. package/components/other/input.tsx +25 -25
  104. package/components/other/label.tsx +26 -26
  105. package/components/other/menubar.tsx +236 -236
  106. package/components/other/mobile-icon.tsx +21 -21
  107. package/components/other/navigation-menu.tsx +128 -128
  108. package/components/other/pagination.tsx +117 -117
  109. package/components/other/popover.tsx +31 -31
  110. package/components/other/progress.tsx +28 -28
  111. package/components/other/radio-group.tsx +44 -44
  112. package/components/other/resizable.tsx +45 -45
  113. package/components/other/scroll-area.tsx +48 -48
  114. package/components/other/select.tsx +160 -160
  115. package/components/other/separator.tsx +31 -31
  116. package/components/other/sheet.tsx +140 -140
  117. package/components/other/skeleton.tsx +15 -15
  118. package/components/other/slider.tsx +28 -28
  119. package/components/other/social-icons.tsx +39 -39
  120. package/components/other/sonner.tsx +31 -31
  121. package/components/other/switch.tsx +29 -29
  122. package/components/other/table.tsx +117 -117
  123. package/components/other/tabs.tsx +55 -55
  124. package/components/other/textarea.tsx +24 -24
  125. package/components/other/toast.tsx +122 -122
  126. package/components/other/toaster.tsx +35 -35
  127. package/components/other/toggle-group.tsx +61 -61
  128. package/components/other/toggle.tsx +45 -45
  129. package/components/other/tooltip.tsx +30 -30
  130. package/components/theme-provider.tsx +8 -8
  131. package/components/types.ts +49 -49
  132. package/dist/.next/types/app/api/about/route.js +52 -0
  133. package/dist/.next/types/app/api/blog/route.js +52 -0
  134. package/dist/.next/types/app/api/files/route.js +52 -0
  135. package/dist/.next/types/app/api/schedule/route.js +52 -0
  136. package/dist/.next/types/app/api/sync-user/route.js +52 -0
  137. package/dist/.next/types/app/layout.js +22 -0
  138. package/dist/.next/types/app/page.js +22 -0
  139. package/dist/app/about/page.jsx +258 -0
  140. package/dist/app/adRequest/page.jsx +531 -0
  141. package/dist/app/analytics/page.jsx +298 -0
  142. package/dist/app/api/about/route.js +285 -0
  143. package/dist/app/api/adRequest/route.js +440 -0
  144. package/dist/app/api/analytics/[reportType]/route.js +357 -0
  145. package/dist/app/api/bio/route.js +293 -0
  146. package/dist/app/api/blog/route.js +366 -0
  147. package/dist/app/api/chat/route.js +58 -0
  148. package/dist/app/api/contact/route.js +163 -0
  149. package/dist/app/api/contacts/route.js +234 -0
  150. package/dist/app/api/files/route.js +444 -0
  151. package/dist/app/api/gallery-data/route.js +719 -0
  152. package/dist/app/api/schedule/route.js +461 -0
  153. package/dist/app/api/sync-user/route.js +186 -0
  154. package/dist/app/api/trial-request/route.js +165 -0
  155. package/dist/app/blog/[id]/page.jsx +312 -0
  156. package/dist/app/blog/page.jsx +210 -0
  157. package/dist/app/constants/about.js +32 -0
  158. package/dist/app/constants/adRequest.js +113 -0
  159. package/dist/app/constants/contact.js +40 -0
  160. package/dist/app/constants/faq.js +36 -0
  161. package/dist/app/constants/gallery.js +42 -0
  162. package/dist/app/constants/page.js +69 -0
  163. package/dist/app/constants/schedule.js +71 -0
  164. package/dist/app/contact/page.jsx +119 -0
  165. package/dist/app/faq/page.jsx +97 -0
  166. package/dist/app/gallery/page.jsx +281 -0
  167. package/dist/app/layout.jsx +45 -0
  168. package/dist/app/not-found.jsx +14 -0
  169. package/dist/app/page.jsx +324 -0
  170. package/dist/app/schedule/page.jsx +500 -0
  171. package/dist/components/addOns/functional/BioEditor.jsx +187 -0
  172. package/dist/components/addOns/functional/CalendlyWidget.jsx +61 -0
  173. package/dist/components/addOns/functional/ClassList.jsx +158 -0
  174. package/dist/components/addOns/functional/ClassPopup.jsx +300 -0
  175. package/dist/components/addOns/functional/ContactForm.jsx +219 -0
  176. package/dist/components/addOns/functional/FileUploader.jsx +222 -0
  177. package/dist/components/addOns/functional/ImageDescCarousel.jsx +491 -0
  178. package/dist/components/addOns/functional/NewUserAnalytics.jsx +71 -0
  179. package/dist/components/addOns/functional/ScheduleCarousel.jsx +68 -0
  180. package/dist/components/addOns/functional/aboutSections/AboutSection.jsx +372 -0
  181. package/dist/components/addOns/functional/aboutSections/constants/aboutSection.js +65 -0
  182. package/dist/components/addOns/functional/blogSections/BlogDashboard.jsx +111 -0
  183. package/dist/components/addOns/functional/blogSections/BlogFormPopUp.jsx +465 -0
  184. package/dist/components/addOns/functional/blogSections/BlogList.jsx +170 -0
  185. package/dist/components/addOns/functional/blogSections/BlogSidebar.jsx +35 -0
  186. package/dist/components/addOns/functional/blogSections/constants/blogDashboard.js +28 -0
  187. package/dist/components/addOns/functional/blogSections/constants/blogFormPopUp.js +97 -0
  188. package/dist/components/addOns/functional/blogSections/constants/blogList.js +22 -0
  189. package/dist/components/addOns/functional/blogSections/constants/blogSidebar.js +15 -0
  190. package/dist/components/addOns/functional/contactsDashboard/ContactsDashboard.jsx +355 -0
  191. package/dist/components/addOns/functional/contactsDashboard/constants/contactsDashboard.js +70 -0
  192. package/dist/components/addOns/functional/galleries/GalleryComplex.jsx +605 -0
  193. package/dist/components/addOns/functional/galleries/GallerySimple.jsx +363 -0
  194. package/dist/components/addOns/functional/galleries/constants/galleryComplex.js +106 -0
  195. package/dist/components/addOns/functional/galleries/constants/gallerySimple.js +76 -0
  196. package/dist/components/addOns/functional/schedules/ScheduleGridOne.jsx +167 -0
  197. package/dist/components/addOns/functional/schedules/ScheduleGridTwo.jsx +100 -0
  198. package/dist/components/addOns/functional/schedules/ScheduleGridTwoBasic.jsx +97 -0
  199. package/dist/components/addOns/functional/schedules/SchedulerForm.jsx +188 -0
  200. package/dist/components/addOns/functional/schedules/constants/ScheduleGridTwo.js +40 -0
  201. package/dist/components/addOns/functional/schedules/constants/ScheduleGridTwoBasic.js +40 -0
  202. package/dist/components/addOns/functional/schedules/constants/SchedulerForm.js +65 -0
  203. package/dist/components/addOns/functional/schedules/constants/scheduleGridOne.js +54 -0
  204. package/dist/components/addOns/non-functional/AnnouncementBanner.jsx +24 -0
  205. package/dist/components/addOns/non-functional/FeaturesSection.jsx +38 -0
  206. package/dist/components/addOns/non-functional/HeroSection.jsx +71 -0
  207. package/dist/components/addOns/non-functional/Heros/HeroSection.jsx +71 -0
  208. package/dist/components/addOns/non-functional/IconBubble.jsx +36 -0
  209. package/dist/components/addOns/non-functional/SampleCarousel.jsx +114 -0
  210. package/dist/components/addOns/non-functional/Testimonials.jsx +177 -0
  211. package/dist/components/addOns/non-functional/ThreeSetGallery.jsx +40 -0
  212. package/dist/components/addOns/non-functional/aboutSections/AboutSection.jsx +35 -0
  213. package/dist/components/addOns/non-functional/aboutSections/constants/aboutSection.js +24 -0
  214. package/dist/components/addOns/non-functional/imageCarousels/ProductSlider.jsx +80 -0
  215. package/dist/components/addOns/non-functional/imageCarousels/ProgramCarousel.jsx +155 -0
  216. package/dist/components/addOns/non-functional/imageCarousels/constants/programCarousel.js +39 -0
  217. package/dist/components/addOns/non-functional/imageCarousels/constants/programSlider.js +36 -0
  218. package/dist/components/addOns/non-functional/spinner.jsx +13 -0
  219. package/dist/components/footers/footer.jsx +217 -0
  220. package/dist/components/navBars/navbar.jsx +159 -0
  221. package/dist/components/other/accordion.jsx +40 -0
  222. package/dist/components/other/admin-menu.jsx +34 -0
  223. package/dist/components/other/alert-dialog.jsx +64 -0
  224. package/dist/components/other/alert.jsx +41 -0
  225. package/dist/components/other/aspect-ratio.jsx +4 -0
  226. package/dist/components/other/avatar.jsx +31 -0
  227. package/dist/components/other/badge.jsx +32 -0
  228. package/dist/components/other/breadcrumb.jsx +57 -0
  229. package/dist/components/other/button.jsx +322 -0
  230. package/dist/components/other/calendar.jsx +43 -0
  231. package/dist/components/other/card.jsx +44 -0
  232. package/dist/components/other/carousel.jsx +140 -0
  233. package/dist/components/other/chart.jsx +182 -0
  234. package/dist/components/other/checkbox.jsx +26 -0
  235. package/dist/components/other/collapsible.jsx +6 -0
  236. package/dist/components/other/command.jsx +68 -0
  237. package/dist/components/other/context-menu.jsx +88 -0
  238. package/dist/components/other/dialog.jsx +60 -0
  239. package/dist/components/other/drawer.jsx +60 -0
  240. package/dist/components/other/dropdown-menu.jsx +90 -0
  241. package/dist/components/other/form.jsx +89 -0
  242. package/dist/components/other/hover-card.jsx +23 -0
  243. package/dist/components/other/input-otp.jsx +46 -0
  244. package/dist/components/other/input.jsx +19 -0
  245. package/dist/components/other/label.jsx +23 -0
  246. package/dist/components/other/login-popup.jsx +1 -0
  247. package/dist/components/other/menubar.jsx +96 -0
  248. package/dist/components/other/mobile-icon.jsx +11 -0
  249. package/dist/components/other/navigation-menu.jsx +62 -0
  250. package/dist/components/other/pagination.jsx +63 -0
  251. package/dist/components/other/popover.jsx +25 -0
  252. package/dist/components/other/progress.jsx +23 -0
  253. package/dist/components/other/radio-group.jsx +31 -0
  254. package/dist/components/other/resizable.jsx +29 -0
  255. package/dist/components/other/scroll-area.jsx +36 -0
  256. package/dist/components/other/select.jsx +83 -0
  257. package/dist/components/other/separator.jsx +21 -0
  258. package/dist/components/other/sheet.jsx +74 -0
  259. package/dist/components/other/signup-popup.jsx +1 -0
  260. package/dist/components/other/skeleton.jsx +17 -0
  261. package/dist/components/other/slider.jsx +26 -0
  262. package/dist/components/other/social-icons.jsx +15 -0
  263. package/dist/components/other/sonner.jsx +27 -0
  264. package/dist/components/other/switch.jsx +23 -0
  265. package/dist/components/other/table.jsx +56 -0
  266. package/dist/components/other/tabs.jsx +32 -0
  267. package/dist/components/other/textarea.jsx +19 -0
  268. package/dist/components/other/toast.jsx +58 -0
  269. package/dist/components/other/toaster.jsx +31 -0
  270. package/dist/components/other/toggle-group.jsx +41 -0
  271. package/dist/components/other/toggle.jsx +39 -0
  272. package/dist/components/other/tooltip.jsx +24 -0
  273. package/dist/components/theme-provider.jsx +18 -0
  274. package/dist/components/types.js +1 -0
  275. package/dist/hooks/use-toast.js +135 -0
  276. package/dist/lib/auth-context.jsx +144 -0
  277. package/dist/lib/constants/about.js +32 -0
  278. package/dist/lib/constants/adRequest.js +113 -0
  279. package/dist/lib/constants/contact.js +40 -0
  280. package/dist/lib/constants/faq.js +36 -0
  281. package/dist/lib/constants/gallery.js +42 -0
  282. package/dist/lib/constants/page.js +69 -0
  283. package/dist/lib/constants/schedule.js +71 -0
  284. package/dist/lib/google-analytics.jsx +148 -0
  285. package/dist/lib/utils.js +9 -0
  286. package/dist/lib/verify-user.js +142 -0
  287. package/dist/middleware.js +37 -0
  288. package/dist/tailwind.config.js +86 -0
  289. package/dist/tsconfig.tsbuildinfo +1 -0
  290. package/hooks/use-toast.ts +188 -188
  291. package/lib/auth-context.tsx +130 -130
  292. package/lib/constants/about.ts +34 -34
  293. package/lib/constants/adRequest.ts +113 -113
  294. package/lib/constants/contact.ts +40 -40
  295. package/lib/constants/faq.ts +34 -21
  296. package/lib/constants/gallery.ts +42 -42
  297. package/lib/constants/page.ts +69 -69
  298. package/lib/constants/schedule.ts +71 -71
  299. package/lib/google-analytics.tsx +97 -97
  300. package/lib/verify-user.ts +117 -117
  301. package/middleware.ts +42 -42
  302. package/netlify.toml +5 -5
  303. package/next.config.js +10 -10
  304. package/package.json +115 -115
  305. package/tailwind.config.ts +89 -89
  306. package/tsconfig.json +23 -23
@@ -1,731 +1,731 @@
1
- // src/components/addOns/functional/ImageDescCarousel.tsx
2
- "use client";
3
-
4
- import { useState, useEffect, useRef } from "react";
5
- import {
6
- ActionButton,
7
- EditIconButton,
8
- TrashIconButton,
9
- CloseButton,
10
- SubmitButton,
11
- CancelButton,
12
- NextButton,
13
- PrevButton,
14
- FilterButton,
15
- DeleteButton,
16
- } from "@/components/other/button";
17
- import { Card } from "@/components/other/card";
18
- import Image from "next/image";
19
- import { Upload, ChevronRight, X, ChevronLeft } from "lucide-react";
20
- import { motion, useScroll, useTransform } from "framer-motion";
21
- import { useAuth, useUser } from "@clerk/nextjs";
22
- import { StrapiUser, UploadedImage, Category } from "@/components/types";
23
-
24
- interface ImageDescCarouselProps {
25
- user: StrapiUser | null;
26
- uploadedImages: UploadedImage[];
27
- setUploadedImages: (images: UploadedImage[]) => void;
28
- error: string | null;
29
- setError: (error: string | null) => void;
30
- isLoading: boolean;
31
- setIsLoading: (isLoading: boolean) => void;
32
- handleImageUpload: (
33
- e: React.FormEvent<HTMLFormElement>,
34
- file: File | null,
35
- title: string,
36
- description: string,
37
- category: Category
38
- ) => Promise<void>;
39
- handleDeleteImage: (documentId: string) => Promise<void>;
40
- }
41
-
42
- const Slideshow = ({
43
- images,
44
- altPrefix,
45
- currentSlide,
46
- setCurrentSlide,
47
- isAdmin,
48
- handleImageClick,
49
- handleEditImage,
50
- handleDeleteImage,
51
- }: {
52
- images: UploadedImage[];
53
- altPrefix: string;
54
- currentSlide: number;
55
- setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
56
- isAdmin: boolean;
57
- handleImageClick: (image: UploadedImage) => void;
58
- handleEditImage: (image: UploadedImage) => void;
59
- handleDeleteImage: (documentId: string) => void;
60
- }) => {
61
- const slideshowRef = useRef<HTMLDivElement>(null);
62
-
63
- const goToPrev = () => setCurrentSlide((prev) => (prev - 1 + images.length) % images.length);
64
- const goToNext = () => setCurrentSlide((prev) => (prev + 1) % images.length);
65
-
66
- if (!images.length || !images[currentSlide]) {
67
- return (
68
- <Card
69
- className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[600px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 flex items-center justify-center supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
70
- ref={slideshowRef}
71
- >
72
- <p className="text-gray-600 text-lg">No images available</p>
73
- </Card>
74
- );
75
- }
76
-
77
- return (
78
- <div
79
- className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[600px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
80
- ref={slideshowRef}
81
- >
82
- <div
83
- className="relative w-full h-full cursor-pointer"
84
- onClick={() => handleImageClick(images[currentSlide])}
85
- >
86
- <Image
87
- src={images[currentSlide].url}
88
- alt={
89
- images[currentSlide].title?.trim() &&
90
- !images[currentSlide].title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
91
- ? images[currentSlide].title
92
- : altPrefix
93
- }
94
- fill
95
- className="object-cover rounded-2xl transition-transform duration-700 hover:scale-110"
96
- sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
97
- loading="lazy"
98
- quality={85}
99
- />
100
- {isAdmin && (
101
- <div className="absolute top-2 right-2 flex space-x-2 opacity-0 hover:opacity-100 transition-opacity duration-500">
102
- <EditIconButton
103
- onClick={(e) => {
104
- e.stopPropagation();
105
- handleEditImage(images[currentSlide]);
106
- }}
107
- />
108
- <TrashIconButton
109
- onClick={(e) => {
110
- e.stopPropagation();
111
- handleDeleteImage(images[currentSlide].documentId);
112
- }}
113
- />
114
- </div>
115
- )}
116
- </div>
117
- {images.length > 1 && (
118
- <>
119
- <PrevButton
120
- onClick={(e) => {
121
- e.stopPropagation();
122
- goToPrev();
123
- }}
124
- />
125
- <NextButton
126
- onClick={(e) => {
127
- e.stopPropagation();
128
- goToNext();
129
- }}
130
- />
131
- </>
132
- )}
133
- </div>
134
- );
135
- };
136
-
137
- export function ImageDescCarousel({
138
- user,
139
- uploadedImages,
140
- setUploadedImages,
141
- error,
142
- setError,
143
- isLoading,
144
- setIsLoading,
145
- handleImageUpload,
146
- handleDeleteImage,
147
- }: ImageDescCarouselProps) {
148
- const { isSignedIn } = useUser();
149
- const { getToken } = useAuth();
150
- const [activeTab, setActiveTab] = useState<Category>("indoor");
151
- const [currentSlide, setCurrentSlide] = useState(0);
152
- const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
153
- const [isEditModalOpen, setIsEditModalOpen] = useState(false);
154
- const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
155
- const [documentIdToDelete, setDocumentIdToDelete] = useState<string | null>(null);
156
- const [selectedImage, setSelectedImage] = useState<UploadedImage | null>(null);
157
- const [uploadForm, setUploadForm] = useState<{
158
- file: File | null;
159
- title: string;
160
- description: string;
161
- category: Category;
162
- }>({ file: null, title: "", description: "", category: "indoor" });
163
- const [editForm, setEditForm] = useState<{
164
- id: number;
165
- documentId: string;
166
- title: string;
167
- description: string;
168
- category: Category;
169
- file: File | null;
170
- }>({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
171
- const [isSubmitting, setIsSubmitting] = useState(false);
172
-
173
- const containerRef = useRef<HTMLDivElement>(null);
174
- const { scrollYProgress } = useScroll({
175
- target: containerRef,
176
- offset: ["start end", "end start"],
177
- });
178
- const parallaxY = useTransform(scrollYProgress, [0, 1], [0, -50]);
179
-
180
- const isAdmin = isSignedIn && !!user?.businessAdminId || false;
181
-
182
- useEffect(() => {
183
- const isAnyModalOpen = isUploadModalOpen || isEditModalOpen || isConfirmDeleteOpen || !!selectedImage;
184
- if (isAnyModalOpen) {
185
- document.body.style.overflow = "hidden";
186
- } else {
187
- document.body.style.overflow = "";
188
- }
189
- window.dispatchEvent(
190
- new CustomEvent("modalStateChange", { detail: { isOpen: isAnyModalOpen } })
191
- );
192
-
193
- return () => {
194
- document.body.style.overflow = "";
195
- };
196
- }, [isUploadModalOpen, isEditModalOpen, isConfirmDeleteOpen, selectedImage]);
197
-
198
- const filteredImages = uploadedImages.filter(
199
- (img) => (img.category || "none") === activeTab
200
- );
201
-
202
- const handleImageClick = (image: UploadedImage) => {
203
- setSelectedImage(image);
204
- };
205
-
206
- const handleCloseModal = () => {
207
- setSelectedImage(null);
208
- };
209
-
210
- const openEditModal = (image: UploadedImage) => {
211
- if (!isAdmin) {
212
- setError("Unauthorized: Only admins can edit images");
213
- return;
214
- }
215
- setEditForm({
216
- id: image.id,
217
- documentId: image.documentId,
218
- title: image.title?.trim() && !image.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) ? image.title : "",
219
- description: image.description || "",
220
- category: image.category && image.category !== "none" ? image.category : "indoor",
221
- file: null,
222
- });
223
- setIsEditModalOpen(true);
224
- };
225
-
226
- const handleCloseEditModal = () => {
227
- setIsEditModalOpen(false);
228
- setEditForm({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
229
- };
230
-
231
- const handleCloseUploadModal = () => {
232
- setIsUploadModalOpen(false);
233
- setUploadForm({ file: null, title: "", description: "", category: "indoor" });
234
- };
235
-
236
- const openConfirmDelete = (documentId: string) => {
237
- if (!isAdmin) {
238
- setError("Unauthorized: Only admins can delete images");
239
- return;
240
- }
241
- setDocumentIdToDelete(documentId);
242
- setIsConfirmDeleteOpen(true);
243
- };
244
-
245
- const handleConfirmDelete = async () => {
246
- if (!isAdmin) {
247
- setError("Unauthorized: Only admins can delete images");
248
- setIsConfirmDeleteOpen(false);
249
- return;
250
- }
251
- if (documentIdToDelete !== null) {
252
- setIsLoading(true);
253
- try {
254
- await handleDeleteImage(documentIdToDelete);
255
- } finally {
256
- setIsLoading(false);
257
- setIsConfirmDeleteOpen(false);
258
- setDocumentIdToDelete(null);
259
- }
260
- }
261
- };
262
-
263
- const handleCancelDelete = () => {
264
- setIsConfirmDeleteOpen(false);
265
- setDocumentIdToDelete(null);
266
- };
267
-
268
- const handleEditImage = async (e: React.FormEvent<HTMLFormElement>) => {
269
- e.preventDefault();
270
- if (!isAdmin) {
271
- console.error("ImageDescCarousel: Unauthorized edit attempt", {
272
- isSignedIn,
273
- businessAdminId: user?.businessAdminId,
274
- });
275
- setError("Unauthorized: Only admins can edit images");
276
- return;
277
- }
278
-
279
- try {
280
- setIsLoading(true);
281
- setIsSubmitting(true);
282
- const token = await getToken();
283
- if (!token) {
284
- console.error("ImageDescCarousel: No authentication token available");
285
- setError("Authentication error: Please log in again");
286
- return;
287
- }
288
-
289
- const formData = new FormData();
290
- formData.append("documentId", editForm.documentId);
291
- formData.append("title", editForm.title || `Image ${new Date().toISOString()}`);
292
- formData.append("description", editForm.description || "");
293
- formData.append("category", editForm.category);
294
- if (editForm.file) {
295
- formData.append("file", editForm.file);
296
- }
297
-
298
- const response = await fetch("/api/gallery-data", {
299
- method: "PUT",
300
- headers: {
301
- Authorization: `Bearer ${token}`,
302
- },
303
- body: formData,
304
- });
305
-
306
- if (!response.ok) {
307
- const errorData = await response.json();
308
- console.error("ImageDescCarousel: Edit failed", { status: response.status, errorData });
309
- if (response.status === 401) {
310
- setError("Authentication error: Please log in again");
311
- return;
312
- }
313
- throw new Error(errorData.error || `Failed to edit image (Status: ${response.status})`);
314
- }
315
-
316
- const { data } = await response.json();
317
- setUploadedImages(data || []);
318
- setError(null);
319
- setIsEditModalOpen(false);
320
- setEditForm({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
321
- } catch (err) {
322
- console.error("ImageDescCarousel: Edit Error", err);
323
- setError(err instanceof Error ? err.message : "Failed to edit image");
324
- } finally {
325
- setIsLoading(false);
326
- setIsSubmitting(false);
327
- }
328
- };
329
-
330
- const sectionVariants = {
331
- hidden: { opacity: 0 },
332
- visible: {
333
- opacity: 1,
334
- transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1], staggerChildren: 0.1 },
335
- },
336
- };
337
-
338
- const modalVariants = {
339
- hidden: { opacity: 0, y: "100vh" },
340
- visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } },
341
- exit: { opacity: 0, y: "100vh", transition: { duration: 0.3, ease: "easeIn" } },
342
- };
343
-
344
- return (
345
- <div className="w-full">
346
- {/* Gallery Section */}
347
- <motion.section
348
- variants={sectionVariants}
349
- initial="hidden"
350
- whileInView="visible"
351
- viewport={{ once: true }}
352
- className="relative py-12 sm:py-16 lg:pb-24 w-full bg-gray-50/50 backdrop-blur-sm"
353
- ref={containerRef}
354
- style={{ y: parallaxY }}
355
- >
356
- <div className="relative z-10 w-full px-4 sm:px-6 lg:px-8">
357
- {error && <p className="text-red-600 text-lg text-center mb-8">{error}</p>}
358
- {user && !isAdmin && (
359
- <p className="text-yellow-600 text-lg text-center mb-8">
360
- You are logged in but do not have admin privileges.
361
- </p>
362
- )}
363
- {isAdmin && (
364
- <div className="flex justify-center mb-12">
365
- <ActionButton
366
- onClick={() => setIsUploadModalOpen(true)}
367
- className="flex items-center"
368
- disabled={isLoading || isSubmitting}
369
- >
370
- <Upload className="mr-2 h-4 w-4" />
371
- Upload New Image
372
- </ActionButton>
373
- </div>
374
- )}
375
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8 lg:gap-12 items-start">
376
- <div className="h-auto min-h-[600px] bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl p-6 sm:p-8 lg:p-10 rounded-3xl flex flex-col space-y-4 sm:space-y-6 order-2 max-w-3xl mx-auto md:max-w-none md:mx-0 md:order-1 supports-[not(backdrop-filter:blur(10px))]:bg-white/20">
377
- <h2 className="text-2xl sm:text-3xl lg:text-4xl font-extrabold text-gray-900">
378
- {filteredImages[currentSlide]?.title?.trim() &&
379
- !filteredImages[currentSlide]?.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
380
- ? filteredImages[currentSlide].title
381
- : "Image Gallery"}
382
- </h2>
383
- <div className="text-sm sm:text-base lg:text-lg text-gray-700">
384
- {filteredImages.length > 0 && currentSlide >= 0 && currentSlide < filteredImages.length ? (
385
- (filteredImages[currentSlide].description || "")
386
- .split("\n")
387
- .filter((paragraph) => paragraph.trim() !== "")
388
- .map((paragraph, index) => (
389
- <p key={index} className="mb-4">
390
- {paragraph}
391
- </p>
392
- )) || <p className="mb-4">No description available.</p>
393
- ) : (
394
- <p className="mb-4">No description available.</p>
395
- )}
396
- </div>
397
- </div>
398
- <div className="order-1 md:order-2">
399
- <Slideshow
400
- images={filteredImages}
401
- altPrefix="Image Gallery"
402
- currentSlide={currentSlide}
403
- setCurrentSlide={setCurrentSlide}
404
- isAdmin={isAdmin}
405
- handleImageClick={handleImageClick}
406
- handleEditImage={openEditModal}
407
- handleDeleteImage={openConfirmDelete}
408
- />
409
- <div className="flex flex-wrap justify-center gap-2 sm:gap-4 mt-6 z-20">
410
- {["indoor", "outdoor", "commercial"].map((tab) => (
411
- <FilterButton
412
- key={tab}
413
- isActive={activeTab === tab}
414
- onClick={() => {
415
- setActiveTab(tab as Category);
416
- setCurrentSlide(0);
417
- }}
418
- >
419
- {tab.charAt(0).toUpperCase() + tab.slice(1)}
420
- </FilterButton>
421
- ))}
422
- </div>
423
- </div>
424
- </div>
425
- </div>
426
- </motion.section>
427
-
428
- {/* Upload Modal */}
429
- {isAdmin && isUploadModalOpen && (
430
- <motion.div
431
- variants={modalVariants}
432
- initial="hidden"
433
- animate="visible"
434
- exit="exit"
435
- className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
436
- onClick={handleCloseUploadModal}
437
- aria-modal="true"
438
- role="dialog"
439
- >
440
- <div
441
- className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
442
- onClick={(e) => e.stopPropagation()}
443
- >
444
- <CloseButton onClick={handleCloseUploadModal}>
445
- <X className="h-6 w-6 sm:h-8 sm:w-8" />
446
- </CloseButton>
447
- <h3 className="text-xl font-bold text-white mb-4">Upload Image</h3>
448
- <form
449
- onSubmit={(e) => {
450
- setIsSubmitting(true);
451
- handleImageUpload(e, uploadForm.file, uploadForm.title, uploadForm.description, uploadForm.category).then(() => {
452
- handleCloseUploadModal();
453
- setIsSubmitting(false);
454
- });
455
- }}
456
- className="space-y-4"
457
- >
458
- <div>
459
- <label htmlFor="image-upload" className="block text-sm font-medium text-gray-300 mb-1">
460
- Choose Image
461
- </label>
462
- <input
463
- type="file"
464
- accept="image/jpeg,image/png,image/gif"
465
- onChange={(e) => setUploadForm({ ...uploadForm, file: e.target.files?.[0] || null })}
466
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
467
- id="image-upload"
468
- disabled={isSubmitting}
469
- />
470
- {uploadForm.file && (
471
- <p className="mt-2 text-gray-300 text-sm max-[320px]:text-xs">Selected: {uploadForm.file.name}</p>
472
- )}
473
- </div>
474
- <div>
475
- <label htmlFor="image-title" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
476
- Image Title (optional)
477
- </label>
478
- <input
479
- id="image-title"
480
- value={uploadForm.title}
481
- onChange={(e) => setUploadForm({ ...uploadForm, title: e.target.value })}
482
- placeholder="Enter image title"
483
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
484
- disabled={isSubmitting}
485
- />
486
- </div>
487
- <div>
488
- <label htmlFor="image-description" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
489
- Image Description (optional)
490
- </label>
491
- <textarea
492
- id="image-description"
493
- value={uploadForm.description}
494
- onChange={(e) => setUploadForm({ ...uploadForm, description: e.target.value })}
495
- placeholder="Enter image description"
496
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px] max-[768px]:text-sm max-[768px]:min-h-[80px] max-[320px]:text-xs max-[320px]:p-2"
497
- disabled={isSubmitting}
498
- />
499
- </div>
500
- <div>
501
- <label htmlFor="image-category" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
502
- Category
503
- </label>
504
- <select
505
- id="image-category"
506
- value={uploadForm.category}
507
- onChange={(e) =>
508
- setUploadForm({
509
- ...uploadForm,
510
- category: e.target.value as Category,
511
- })
512
- }
513
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
514
- disabled={isSubmitting}
515
- >
516
- <option value="indoor">Indoor</option>
517
- <option value="outdoor">Outdoor</option>
518
- <option value="commercial">Commercial</option>
519
- {/* Add project-specific categories, e.g.:
520
- <option value="landscape-boulders">Landscape Boulders</option>
521
- */}
522
- </select>
523
- </div>
524
- <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
525
- <SubmitButton type="submit" disabled={isSubmitting || !uploadForm.file}>
526
- {isSubmitting ? "Uploading..." : "Upload"}
527
- </SubmitButton>
528
- <CancelButton onClick={handleCloseUploadModal} disabled={isSubmitting} />
529
- </div>
530
- {error && <p className="text-red-400 text-sm font-medium max-[320px]:text-xs">{error}</p>}
531
- </form>
532
- </div>
533
- </motion.div>
534
- )}
535
-
536
- {/* Edit Modal */}
537
- {isAdmin && isEditModalOpen && (
538
- <motion.div
539
- variants={modalVariants}
540
- initial="hidden"
541
- animate="visible"
542
- exit="exit"
543
- className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
544
- onClick={handleCloseEditModal}
545
- aria-modal="true"
546
- role="dialog"
547
- >
548
- <div
549
- className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
550
- onClick={(e) => e.stopPropagation()}
551
- >
552
- <CloseButton onClick={handleCloseEditModal}>
553
- <X className="h-6 w-6 sm:h-8 sm:w-8" />
554
- </CloseButton>
555
- <h3 className="text-xl font-bold text-white mb-4">Edit Image</h3>
556
- <form
557
- onSubmit={handleEditImage}
558
- className="space-y-4"
559
- >
560
- <div>
561
- <label htmlFor="edit-image" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
562
- Replace Image (optional)
563
- </label>
564
- <input
565
- id="edit-image"
566
- type="file"
567
- accept="image/jpeg,image/png,image/gif"
568
- onChange={(e) => setEditForm({ ...editForm, file: e.target.files?.[0] || null })}
569
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
570
- disabled={isSubmitting}
571
- />
572
- {editForm.file && (
573
- <p className="mt-2 text-gray-300 text-sm max-[320px]:text-xs">Selected: {editForm.file.name}</p>
574
- )}
575
- </div>
576
- <div>
577
- <label htmlFor="edit-title" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
578
- Image Title (optional)
579
- </label>
580
- <input
581
- id="edit-title"
582
- value={editForm.title}
583
- onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
584
- placeholder="Enter image title"
585
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
586
- disabled={isSubmitting}
587
- />
588
- </div>
589
- <div>
590
- <label htmlFor="edit-description" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
591
- Image Description (optional)
592
- </label>
593
- <textarea
594
- id="edit-description"
595
- value={editForm.description}
596
- onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
597
- placeholder="Enter image description"
598
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px] max-[768px]:text-sm max-[768px]:min-h-[80px] max-[320px]:text-xs max-[320px]:p-2"
599
- disabled={isSubmitting}
600
- />
601
- </div>
602
- <div>
603
- <label htmlFor="edit-category" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
604
- Category
605
- </label>
606
- <select
607
- id="edit-category"
608
- value={editForm.category}
609
- onChange={(e) =>
610
- setEditForm({
611
- ...editForm,
612
- category: e.target.value as Category,
613
- })
614
- }
615
- className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
616
- disabled={isSubmitting}
617
- >
618
- <option value="indoor">Indoor</option>
619
- <option value="outdoor">Outdoor</option>
620
- <option value="commercial">Commercial</option>
621
- {/* Add project-specific categories, e.g.:
622
- <option value="landscape-boulders">Landscape Boulders</option>
623
- */}
624
- </select>
625
- </div>
626
- <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
627
- <SubmitButton type="submit" disabled={isSubmitting}>
628
- {isSubmitting ? "Saving..." : "Save"}
629
- </SubmitButton>
630
- <CancelButton onClick={handleCloseEditModal} disabled={isSubmitting} />
631
- </div>
632
- {error && <p className="text-red-400 text-sm font-medium max-[320px]:text-xs">{error}</p>}
633
- </form>
634
- </div>
635
- </motion.div>
636
- )}
637
-
638
- {/* Delete Confirmation Modal */}
639
- {isAdmin && isConfirmDeleteOpen && (
640
- <motion.div
641
- variants={modalVariants}
642
- initial="hidden"
643
- animate="visible"
644
- exit="exit"
645
- className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
646
- onClick={handleCancelDelete}
647
- aria-modal="true"
648
- role="dialog"
649
- >
650
- <div
651
- className="relative max-w-sm w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
652
- onClick={(e) => e.stopPropagation()}
653
- >
654
- <CloseButton onClick={handleCancelDelete}>
655
- <X className="h-6 w-6 sm:h-8 sm:w-8" />
656
- </CloseButton>
657
- <h3 className="text-xl font-bold text-white mb-4">Confirm Deletion</h3>
658
- <p className="text-gray-300 mb-6">Are you sure you want to delete this image?</p>
659
- <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
660
- <DeleteButton
661
- onClick={handleConfirmDelete}
662
- disabled={isSubmitting}
663
- >
664
- {isSubmitting ? "Deleting..." : "Delete"}
665
- </DeleteButton>
666
- <CancelButton onClick={handleCancelDelete} disabled={isSubmitting} />
667
- </div>
668
- </div>
669
- </motion.div>
670
- )}
671
-
672
- {/* View Image Modal */}
673
- {selectedImage && (
674
- <motion.div
675
- variants={modalVariants}
676
- initial="hidden"
677
- animate="visible"
678
- exit="exit"
679
- className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10001] p-2 sm:p-4 isolate"
680
- onClick={handleCloseModal}
681
- role="dialog"
682
- aria-modal="true"
683
- aria-labelledby="modal-image"
684
- >
685
- <div
686
- className="relative max-w-5xl w-full mx-2 sm:mx-4 max-h-[90vh] bg-gray-800/50 border border-gray-700/50 rounded-2xl shadow-lg overflow-hidden"
687
- onClick={(e) => e.stopPropagation()}
688
- >
689
- <CloseButton
690
- variant="close-form"
691
- onClick={handleCloseModal}
692
- >
693
- <X className="h-6 w-6 sm:h-8 sm:w-8" />
694
- </CloseButton>
695
- <div className="relative w-full h-[70vh] sm:h-[80vh]">
696
- <Image
697
- src={selectedImage.url}
698
- alt={
699
- selectedImage.title?.trim() &&
700
- !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
701
- ? selectedImage.title
702
- : "Gallery image"
703
- }
704
- fill
705
- className="object-contain rounded-2xl"
706
- quality={85}
707
- />
708
- {(selectedImage.title?.trim() &&
709
- !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) ||
710
- selectedImage.description) && (
711
- <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 sm:p-4">
712
- {selectedImage.title?.trim() &&
713
- !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) && (
714
- <h3 className="text-lg sm:text-xl md:text-2xl font-bold text-white drop-shadow-lg">
715
- {selectedImage.title}
716
- </h3>
717
- )}
718
- {selectedImage.description && (
719
- <p className="text-xs sm:text-sm md:text-base text-gray-200 drop-shadow-md line-clamp-2">
720
- {selectedImage.description}
721
- </p>
722
- )}
723
- </div>
724
- )}
725
- </div>
726
- </div>
727
- </motion.div>
728
- )}
729
- </div>
730
- );
1
+ // src/components/addOns/functional/ImageDescCarousel.tsx
2
+ "use client";
3
+
4
+ import { useState, useEffect, useRef } from "react";
5
+ import {
6
+ ActionButton,
7
+ EditIconButton,
8
+ TrashIconButton,
9
+ CloseButton,
10
+ SubmitButton,
11
+ CancelButton,
12
+ NextButton,
13
+ PrevButton,
14
+ FilterButton,
15
+ DeleteButton,
16
+ } from "@/components/other/button";
17
+ import { Card } from "@/components/other/card";
18
+ import Image from "next/image";
19
+ import { Upload, ChevronRight, X, ChevronLeft } from "lucide-react";
20
+ import { motion, useScroll, useTransform } from "framer-motion";
21
+ import { useAuth, useUser } from "@clerk/nextjs";
22
+ import { StrapiUser, UploadedImage, Category } from "@/components/types";
23
+
24
+ interface ImageDescCarouselProps {
25
+ user: StrapiUser | null;
26
+ uploadedImages: UploadedImage[];
27
+ setUploadedImages: (images: UploadedImage[]) => void;
28
+ error: string | null;
29
+ setError: (error: string | null) => void;
30
+ isLoading: boolean;
31
+ setIsLoading: (isLoading: boolean) => void;
32
+ handleImageUpload: (
33
+ e: React.FormEvent<HTMLFormElement>,
34
+ file: File | null,
35
+ title: string,
36
+ description: string,
37
+ category: Category
38
+ ) => Promise<void>;
39
+ handleDeleteImage: (documentId: string) => Promise<void>;
40
+ }
41
+
42
+ const Slideshow = ({
43
+ images,
44
+ altPrefix,
45
+ currentSlide,
46
+ setCurrentSlide,
47
+ isAdmin,
48
+ handleImageClick,
49
+ handleEditImage,
50
+ handleDeleteImage,
51
+ }: {
52
+ images: UploadedImage[];
53
+ altPrefix: string;
54
+ currentSlide: number;
55
+ setCurrentSlide: React.Dispatch<React.SetStateAction<number>>;
56
+ isAdmin: boolean;
57
+ handleImageClick: (image: UploadedImage) => void;
58
+ handleEditImage: (image: UploadedImage) => void;
59
+ handleDeleteImage: (documentId: string) => void;
60
+ }) => {
61
+ const slideshowRef = useRef<HTMLDivElement>(null);
62
+
63
+ const goToPrev = () => setCurrentSlide((prev) => (prev - 1 + images.length) % images.length);
64
+ const goToNext = () => setCurrentSlide((prev) => (prev + 1) % images.length);
65
+
66
+ if (!images.length || !images[currentSlide]) {
67
+ return (
68
+ <Card
69
+ className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[600px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 flex items-center justify-center supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
70
+ ref={slideshowRef}
71
+ >
72
+ <p className="text-gray-600 text-lg">No images available</p>
73
+ </Card>
74
+ );
75
+ }
76
+
77
+ return (
78
+ <div
79
+ className="relative h-[40vh] min-h-[400px] sm:h-[50vh] lg:h-[600px] rounded-3xl overflow-hidden bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl w-full mx-auto max-w-3xl md:max-w-none md:mx-0 supports-[not(backdrop-filter:blur(10px))]:bg-white/20"
80
+ ref={slideshowRef}
81
+ >
82
+ <div
83
+ className="relative w-full h-full cursor-pointer"
84
+ onClick={() => handleImageClick(images[currentSlide])}
85
+ >
86
+ <Image
87
+ src={images[currentSlide].url}
88
+ alt={
89
+ images[currentSlide].title?.trim() &&
90
+ !images[currentSlide].title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
91
+ ? images[currentSlide].title
92
+ : altPrefix
93
+ }
94
+ fill
95
+ className="object-cover rounded-2xl transition-transform duration-700 hover:scale-110"
96
+ sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
97
+ loading="lazy"
98
+ quality={85}
99
+ />
100
+ {isAdmin && (
101
+ <div className="absolute top-2 right-2 flex space-x-2 opacity-0 hover:opacity-100 transition-opacity duration-500">
102
+ <EditIconButton
103
+ onClick={(e) => {
104
+ e.stopPropagation();
105
+ handleEditImage(images[currentSlide]);
106
+ }}
107
+ />
108
+ <TrashIconButton
109
+ onClick={(e) => {
110
+ e.stopPropagation();
111
+ handleDeleteImage(images[currentSlide].documentId);
112
+ }}
113
+ />
114
+ </div>
115
+ )}
116
+ </div>
117
+ {images.length > 1 && (
118
+ <>
119
+ <PrevButton
120
+ onClick={(e) => {
121
+ e.stopPropagation();
122
+ goToPrev();
123
+ }}
124
+ />
125
+ <NextButton
126
+ onClick={(e) => {
127
+ e.stopPropagation();
128
+ goToNext();
129
+ }}
130
+ />
131
+ </>
132
+ )}
133
+ </div>
134
+ );
135
+ };
136
+
137
+ export function ImageDescCarousel({
138
+ user,
139
+ uploadedImages,
140
+ setUploadedImages,
141
+ error,
142
+ setError,
143
+ isLoading,
144
+ setIsLoading,
145
+ handleImageUpload,
146
+ handleDeleteImage,
147
+ }: ImageDescCarouselProps) {
148
+ const { isSignedIn } = useUser();
149
+ const { getToken } = useAuth();
150
+ const [activeTab, setActiveTab] = useState<Category>("indoor");
151
+ const [currentSlide, setCurrentSlide] = useState(0);
152
+ const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
153
+ const [isEditModalOpen, setIsEditModalOpen] = useState(false);
154
+ const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
155
+ const [documentIdToDelete, setDocumentIdToDelete] = useState<string | null>(null);
156
+ const [selectedImage, setSelectedImage] = useState<UploadedImage | null>(null);
157
+ const [uploadForm, setUploadForm] = useState<{
158
+ file: File | null;
159
+ title: string;
160
+ description: string;
161
+ category: Category;
162
+ }>({ file: null, title: "", description: "", category: "indoor" });
163
+ const [editForm, setEditForm] = useState<{
164
+ id: number;
165
+ documentId: string;
166
+ title: string;
167
+ description: string;
168
+ category: Category;
169
+ file: File | null;
170
+ }>({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
171
+ const [isSubmitting, setIsSubmitting] = useState(false);
172
+
173
+ const containerRef = useRef<HTMLDivElement>(null);
174
+ const { scrollYProgress } = useScroll({
175
+ target: containerRef,
176
+ offset: ["start end", "end start"],
177
+ });
178
+ const parallaxY = useTransform(scrollYProgress, [0, 1], [0, -50]);
179
+
180
+ const isAdmin = isSignedIn && !!user?.businessAdminId || false;
181
+
182
+ useEffect(() => {
183
+ const isAnyModalOpen = isUploadModalOpen || isEditModalOpen || isConfirmDeleteOpen || !!selectedImage;
184
+ if (isAnyModalOpen) {
185
+ document.body.style.overflow = "hidden";
186
+ } else {
187
+ document.body.style.overflow = "";
188
+ }
189
+ window.dispatchEvent(
190
+ new CustomEvent("modalStateChange", { detail: { isOpen: isAnyModalOpen } })
191
+ );
192
+
193
+ return () => {
194
+ document.body.style.overflow = "";
195
+ };
196
+ }, [isUploadModalOpen, isEditModalOpen, isConfirmDeleteOpen, selectedImage]);
197
+
198
+ const filteredImages = uploadedImages.filter(
199
+ (img) => (img.category || "none") === activeTab
200
+ );
201
+
202
+ const handleImageClick = (image: UploadedImage) => {
203
+ setSelectedImage(image);
204
+ };
205
+
206
+ const handleCloseModal = () => {
207
+ setSelectedImage(null);
208
+ };
209
+
210
+ const openEditModal = (image: UploadedImage) => {
211
+ if (!isAdmin) {
212
+ setError("Unauthorized: Only admins can edit images");
213
+ return;
214
+ }
215
+ setEditForm({
216
+ id: image.id,
217
+ documentId: image.documentId,
218
+ title: image.title?.trim() && !image.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) ? image.title : "",
219
+ description: image.description || "",
220
+ category: image.category && image.category !== "none" ? image.category : "indoor",
221
+ file: null,
222
+ });
223
+ setIsEditModalOpen(true);
224
+ };
225
+
226
+ const handleCloseEditModal = () => {
227
+ setIsEditModalOpen(false);
228
+ setEditForm({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
229
+ };
230
+
231
+ const handleCloseUploadModal = () => {
232
+ setIsUploadModalOpen(false);
233
+ setUploadForm({ file: null, title: "", description: "", category: "indoor" });
234
+ };
235
+
236
+ const openConfirmDelete = (documentId: string) => {
237
+ if (!isAdmin) {
238
+ setError("Unauthorized: Only admins can delete images");
239
+ return;
240
+ }
241
+ setDocumentIdToDelete(documentId);
242
+ setIsConfirmDeleteOpen(true);
243
+ };
244
+
245
+ const handleConfirmDelete = async () => {
246
+ if (!isAdmin) {
247
+ setError("Unauthorized: Only admins can delete images");
248
+ setIsConfirmDeleteOpen(false);
249
+ return;
250
+ }
251
+ if (documentIdToDelete !== null) {
252
+ setIsLoading(true);
253
+ try {
254
+ await handleDeleteImage(documentIdToDelete);
255
+ } finally {
256
+ setIsLoading(false);
257
+ setIsConfirmDeleteOpen(false);
258
+ setDocumentIdToDelete(null);
259
+ }
260
+ }
261
+ };
262
+
263
+ const handleCancelDelete = () => {
264
+ setIsConfirmDeleteOpen(false);
265
+ setDocumentIdToDelete(null);
266
+ };
267
+
268
+ const handleEditImage = async (e: React.FormEvent<HTMLFormElement>) => {
269
+ e.preventDefault();
270
+ if (!isAdmin) {
271
+ console.error("ImageDescCarousel: Unauthorized edit attempt", {
272
+ isSignedIn,
273
+ businessAdminId: user?.businessAdminId,
274
+ });
275
+ setError("Unauthorized: Only admins can edit images");
276
+ return;
277
+ }
278
+
279
+ try {
280
+ setIsLoading(true);
281
+ setIsSubmitting(true);
282
+ const token = await getToken();
283
+ if (!token) {
284
+ console.error("ImageDescCarousel: No authentication token available");
285
+ setError("Authentication error: Please log in again");
286
+ return;
287
+ }
288
+
289
+ const formData = new FormData();
290
+ formData.append("documentId", editForm.documentId);
291
+ formData.append("title", editForm.title || `Image ${new Date().toISOString()}`);
292
+ formData.append("description", editForm.description || "");
293
+ formData.append("category", editForm.category);
294
+ if (editForm.file) {
295
+ formData.append("file", editForm.file);
296
+ }
297
+
298
+ const response = await fetch("/api/gallery-data", {
299
+ method: "PUT",
300
+ headers: {
301
+ Authorization: `Bearer ${token}`,
302
+ },
303
+ body: formData,
304
+ });
305
+
306
+ if (!response.ok) {
307
+ const errorData = await response.json();
308
+ console.error("ImageDescCarousel: Edit failed", { status: response.status, errorData });
309
+ if (response.status === 401) {
310
+ setError("Authentication error: Please log in again");
311
+ return;
312
+ }
313
+ throw new Error(errorData.error || `Failed to edit image (Status: ${response.status})`);
314
+ }
315
+
316
+ const { data } = await response.json();
317
+ setUploadedImages(data || []);
318
+ setError(null);
319
+ setIsEditModalOpen(false);
320
+ setEditForm({ id: 0, documentId: "", title: "", description: "", category: "indoor", file: null });
321
+ } catch (err) {
322
+ console.error("ImageDescCarousel: Edit Error", err);
323
+ setError(err instanceof Error ? err.message : "Failed to edit image");
324
+ } finally {
325
+ setIsLoading(false);
326
+ setIsSubmitting(false);
327
+ }
328
+ };
329
+
330
+ const sectionVariants = {
331
+ hidden: { opacity: 0 },
332
+ visible: {
333
+ opacity: 1,
334
+ transition: { duration: 0.8, ease: [0.16, 1, 0.3, 1], staggerChildren: 0.1 },
335
+ },
336
+ };
337
+
338
+ const modalVariants = {
339
+ hidden: { opacity: 0, y: "100vh" },
340
+ visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] } },
341
+ exit: { opacity: 0, y: "100vh", transition: { duration: 0.3, ease: "easeIn" } },
342
+ };
343
+
344
+ return (
345
+ <div className="w-full">
346
+ {/* Gallery Section */}
347
+ <motion.section
348
+ variants={sectionVariants}
349
+ initial="hidden"
350
+ whileInView="visible"
351
+ viewport={{ once: true }}
352
+ className="relative py-12 sm:py-16 lg:pb-24 w-full bg-gray-50/50 backdrop-blur-sm"
353
+ ref={containerRef}
354
+ style={{ y: parallaxY }}
355
+ >
356
+ <div className="relative z-10 w-full px-4 sm:px-6 lg:px-8">
357
+ {error && <p className="text-red-600 text-lg text-center mb-8">{error}</p>}
358
+ {user && !isAdmin && (
359
+ <p className="text-yellow-600 text-lg text-center mb-8">
360
+ You are logged in but do not have admin privileges.
361
+ </p>
362
+ )}
363
+ {isAdmin && (
364
+ <div className="flex justify-center mb-12">
365
+ <ActionButton
366
+ onClick={() => setIsUploadModalOpen(true)}
367
+ className="flex items-center"
368
+ disabled={isLoading || isSubmitting}
369
+ >
370
+ <Upload className="mr-2 h-4 w-4" />
371
+ Upload New Image
372
+ </ActionButton>
373
+ </div>
374
+ )}
375
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8 lg:gap-12 items-start">
376
+ <div className="h-auto min-h-[600px] bg-white/10 backdrop-blur-lg border-2 border-transparent shadow-xl p-6 sm:p-8 lg:p-10 rounded-3xl flex flex-col space-y-4 sm:space-y-6 order-2 max-w-3xl mx-auto md:max-w-none md:mx-0 md:order-1 supports-[not(backdrop-filter:blur(10px))]:bg-white/20">
377
+ <h2 className="text-2xl sm:text-3xl lg:text-4xl font-extrabold text-gray-900">
378
+ {filteredImages[currentSlide]?.title?.trim() &&
379
+ !filteredImages[currentSlide]?.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
380
+ ? filteredImages[currentSlide].title
381
+ : "Image Gallery"}
382
+ </h2>
383
+ <div className="text-sm sm:text-base lg:text-lg text-gray-700">
384
+ {filteredImages.length > 0 && currentSlide >= 0 && currentSlide < filteredImages.length ? (
385
+ (filteredImages[currentSlide].description || "")
386
+ .split("\n")
387
+ .filter((paragraph) => paragraph.trim() !== "")
388
+ .map((paragraph, index) => (
389
+ <p key={index} className="mb-4">
390
+ {paragraph}
391
+ </p>
392
+ )) || <p className="mb-4">No description available.</p>
393
+ ) : (
394
+ <p className="mb-4">No description available.</p>
395
+ )}
396
+ </div>
397
+ </div>
398
+ <div className="order-1 md:order-2">
399
+ <Slideshow
400
+ images={filteredImages}
401
+ altPrefix="Image Gallery"
402
+ currentSlide={currentSlide}
403
+ setCurrentSlide={setCurrentSlide}
404
+ isAdmin={isAdmin}
405
+ handleImageClick={handleImageClick}
406
+ handleEditImage={openEditModal}
407
+ handleDeleteImage={openConfirmDelete}
408
+ />
409
+ <div className="flex flex-wrap justify-center gap-2 sm:gap-4 mt-6 z-20">
410
+ {["indoor", "outdoor", "commercial"].map((tab) => (
411
+ <FilterButton
412
+ key={tab}
413
+ isActive={activeTab === tab}
414
+ onClick={() => {
415
+ setActiveTab(tab as Category);
416
+ setCurrentSlide(0);
417
+ }}
418
+ >
419
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
420
+ </FilterButton>
421
+ ))}
422
+ </div>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ </motion.section>
427
+
428
+ {/* Upload Modal */}
429
+ {isAdmin && isUploadModalOpen && (
430
+ <motion.div
431
+ variants={modalVariants}
432
+ initial="hidden"
433
+ animate="visible"
434
+ exit="exit"
435
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
436
+ onClick={handleCloseUploadModal}
437
+ aria-modal="true"
438
+ role="dialog"
439
+ >
440
+ <div
441
+ className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
442
+ onClick={(e) => e.stopPropagation()}
443
+ >
444
+ <CloseButton onClick={handleCloseUploadModal}>
445
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
446
+ </CloseButton>
447
+ <h3 className="text-xl font-bold text-white mb-4">Upload Image</h3>
448
+ <form
449
+ onSubmit={(e) => {
450
+ setIsSubmitting(true);
451
+ handleImageUpload(e, uploadForm.file, uploadForm.title, uploadForm.description, uploadForm.category).then(() => {
452
+ handleCloseUploadModal();
453
+ setIsSubmitting(false);
454
+ });
455
+ }}
456
+ className="space-y-4"
457
+ >
458
+ <div>
459
+ <label htmlFor="image-upload" className="block text-sm font-medium text-gray-300 mb-1">
460
+ Choose Image
461
+ </label>
462
+ <input
463
+ type="file"
464
+ accept="image/jpeg,image/png,image/gif"
465
+ onChange={(e) => setUploadForm({ ...uploadForm, file: e.target.files?.[0] || null })}
466
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
467
+ id="image-upload"
468
+ disabled={isSubmitting}
469
+ />
470
+ {uploadForm.file && (
471
+ <p className="mt-2 text-gray-300 text-sm max-[320px]:text-xs">Selected: {uploadForm.file.name}</p>
472
+ )}
473
+ </div>
474
+ <div>
475
+ <label htmlFor="image-title" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
476
+ Image Title (optional)
477
+ </label>
478
+ <input
479
+ id="image-title"
480
+ value={uploadForm.title}
481
+ onChange={(e) => setUploadForm({ ...uploadForm, title: e.target.value })}
482
+ placeholder="Enter image title"
483
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
484
+ disabled={isSubmitting}
485
+ />
486
+ </div>
487
+ <div>
488
+ <label htmlFor="image-description" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
489
+ Image Description (optional)
490
+ </label>
491
+ <textarea
492
+ id="image-description"
493
+ value={uploadForm.description}
494
+ onChange={(e) => setUploadForm({ ...uploadForm, description: e.target.value })}
495
+ placeholder="Enter image description"
496
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px] max-[768px]:text-sm max-[768px]:min-h-[80px] max-[320px]:text-xs max-[320px]:p-2"
497
+ disabled={isSubmitting}
498
+ />
499
+ </div>
500
+ <div>
501
+ <label htmlFor="image-category" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
502
+ Category
503
+ </label>
504
+ <select
505
+ id="image-category"
506
+ value={uploadForm.category}
507
+ onChange={(e) =>
508
+ setUploadForm({
509
+ ...uploadForm,
510
+ category: e.target.value as Category,
511
+ })
512
+ }
513
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
514
+ disabled={isSubmitting}
515
+ >
516
+ <option value="indoor">Indoor</option>
517
+ <option value="outdoor">Outdoor</option>
518
+ <option value="commercial">Commercial</option>
519
+ {/* Add project-specific categories, e.g.:
520
+ <option value="landscape-boulders">Landscape Boulders</option>
521
+ */}
522
+ </select>
523
+ </div>
524
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
525
+ <SubmitButton type="submit" disabled={isSubmitting || !uploadForm.file}>
526
+ {isSubmitting ? "Uploading..." : "Upload"}
527
+ </SubmitButton>
528
+ <CancelButton onClick={handleCloseUploadModal} disabled={isSubmitting} />
529
+ </div>
530
+ {error && <p className="text-red-400 text-sm font-medium max-[320px]:text-xs">{error}</p>}
531
+ </form>
532
+ </div>
533
+ </motion.div>
534
+ )}
535
+
536
+ {/* Edit Modal */}
537
+ {isAdmin && isEditModalOpen && (
538
+ <motion.div
539
+ variants={modalVariants}
540
+ initial="hidden"
541
+ animate="visible"
542
+ exit="exit"
543
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
544
+ onClick={handleCloseEditModal}
545
+ aria-modal="true"
546
+ role="dialog"
547
+ >
548
+ <div
549
+ className="relative max-w-5xl w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
550
+ onClick={(e) => e.stopPropagation()}
551
+ >
552
+ <CloseButton onClick={handleCloseEditModal}>
553
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
554
+ </CloseButton>
555
+ <h3 className="text-xl font-bold text-white mb-4">Edit Image</h3>
556
+ <form
557
+ onSubmit={handleEditImage}
558
+ className="space-y-4"
559
+ >
560
+ <div>
561
+ <label htmlFor="edit-image" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
562
+ Replace Image (optional)
563
+ </label>
564
+ <input
565
+ id="edit-image"
566
+ type="file"
567
+ accept="image/jpeg,image/png,image/gif"
568
+ onChange={(e) => setEditForm({ ...editForm, file: e.target.files?.[0] || null })}
569
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
570
+ disabled={isSubmitting}
571
+ />
572
+ {editForm.file && (
573
+ <p className="mt-2 text-gray-300 text-sm max-[320px]:text-xs">Selected: {editForm.file.name}</p>
574
+ )}
575
+ </div>
576
+ <div>
577
+ <label htmlFor="edit-title" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
578
+ Image Title (optional)
579
+ </label>
580
+ <input
581
+ id="edit-title"
582
+ value={editForm.title}
583
+ onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
584
+ placeholder="Enter image title"
585
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
586
+ disabled={isSubmitting}
587
+ />
588
+ </div>
589
+ <div>
590
+ <label htmlFor="edit-description" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
591
+ Image Description (optional)
592
+ </label>
593
+ <textarea
594
+ id="edit-description"
595
+ value={editForm.description}
596
+ onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
597
+ placeholder="Enter image description"
598
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm min-h-[80px] max-[768px]:text-sm max-[768px]:min-h-[80px] max-[320px]:text-xs max-[320px]:p-2"
599
+ disabled={isSubmitting}
600
+ />
601
+ </div>
602
+ <div>
603
+ <label htmlFor="edit-category" className="block text-sm font-medium text-gray-300 mb-1 max-[320px]:text-xs">
604
+ Category
605
+ </label>
606
+ <select
607
+ id="edit-category"
608
+ value={editForm.category}
609
+ onChange={(e) =>
610
+ setEditForm({
611
+ ...editForm,
612
+ category: e.target.value as Category,
613
+ })
614
+ }
615
+ className="w-full p-2 rounded-md bg-gray-700 text-white border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm max-[768px]:text-sm max-[320px]:text-xs max-[320px]:p-2"
616
+ disabled={isSubmitting}
617
+ >
618
+ <option value="indoor">Indoor</option>
619
+ <option value="outdoor">Outdoor</option>
620
+ <option value="commercial">Commercial</option>
621
+ {/* Add project-specific categories, e.g.:
622
+ <option value="landscape-boulders">Landscape Boulders</option>
623
+ */}
624
+ </select>
625
+ </div>
626
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
627
+ <SubmitButton type="submit" disabled={isSubmitting}>
628
+ {isSubmitting ? "Saving..." : "Save"}
629
+ </SubmitButton>
630
+ <CancelButton onClick={handleCloseEditModal} disabled={isSubmitting} />
631
+ </div>
632
+ {error && <p className="text-red-400 text-sm font-medium max-[320px]:text-xs">{error}</p>}
633
+ </form>
634
+ </div>
635
+ </motion.div>
636
+ )}
637
+
638
+ {/* Delete Confirmation Modal */}
639
+ {isAdmin && isConfirmDeleteOpen && (
640
+ <motion.div
641
+ variants={modalVariants}
642
+ initial="hidden"
643
+ animate="visible"
644
+ exit="exit"
645
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10000] p-4 isolate"
646
+ onClick={handleCancelDelete}
647
+ aria-modal="true"
648
+ role="dialog"
649
+ >
650
+ <div
651
+ className="relative max-w-sm w-full mx-4 p-6 bg-gray-800/50 border border-gray-700/50 rounded-lg shadow-lg max-h-[90vh] overflow-y-auto"
652
+ onClick={(e) => e.stopPropagation()}
653
+ >
654
+ <CloseButton onClick={handleCancelDelete}>
655
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
656
+ </CloseButton>
657
+ <h3 className="text-xl font-bold text-white mb-4">Confirm Deletion</h3>
658
+ <p className="text-gray-300 mb-6">Are you sure you want to delete this image?</p>
659
+ <div className="flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-3">
660
+ <DeleteButton
661
+ onClick={handleConfirmDelete}
662
+ disabled={isSubmitting}
663
+ >
664
+ {isSubmitting ? "Deleting..." : "Delete"}
665
+ </DeleteButton>
666
+ <CancelButton onClick={handleCancelDelete} disabled={isSubmitting} />
667
+ </div>
668
+ </div>
669
+ </motion.div>
670
+ )}
671
+
672
+ {/* View Image Modal */}
673
+ {selectedImage && (
674
+ <motion.div
675
+ variants={modalVariants}
676
+ initial="hidden"
677
+ animate="visible"
678
+ exit="exit"
679
+ className="fixed inset-0 bg-black/90 flex items-center justify-center z-[10001] p-2 sm:p-4 isolate"
680
+ onClick={handleCloseModal}
681
+ role="dialog"
682
+ aria-modal="true"
683
+ aria-labelledby="modal-image"
684
+ >
685
+ <div
686
+ className="relative max-w-5xl w-full mx-2 sm:mx-4 max-h-[90vh] bg-gray-800/50 border border-gray-700/50 rounded-2xl shadow-lg overflow-hidden"
687
+ onClick={(e) => e.stopPropagation()}
688
+ >
689
+ <CloseButton
690
+ variant="close-form"
691
+ onClick={handleCloseModal}
692
+ >
693
+ <X className="h-6 w-6 sm:h-8 sm:w-8" />
694
+ </CloseButton>
695
+ <div className="relative w-full h-[70vh] sm:h-[80vh]">
696
+ <Image
697
+ src={selectedImage.url}
698
+ alt={
699
+ selectedImage.title?.trim() &&
700
+ !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/)
701
+ ? selectedImage.title
702
+ : "Gallery image"
703
+ }
704
+ fill
705
+ className="object-contain rounded-2xl"
706
+ quality={85}
707
+ />
708
+ {(selectedImage.title?.trim() &&
709
+ !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) ||
710
+ selectedImage.description) && (
711
+ <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 sm:p-4">
712
+ {selectedImage.title?.trim() &&
713
+ !selectedImage.title.match(/Image \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/) && (
714
+ <h3 className="text-lg sm:text-xl md:text-2xl font-bold text-white drop-shadow-lg">
715
+ {selectedImage.title}
716
+ </h3>
717
+ )}
718
+ {selectedImage.description && (
719
+ <p className="text-xs sm:text-sm md:text-base text-gray-200 drop-shadow-md line-clamp-2">
720
+ {selectedImage.description}
721
+ </p>
722
+ )}
723
+ </div>
724
+ )}
725
+ </div>
726
+ </div>
727
+ </motion.div>
728
+ )}
729
+ </div>
730
+ );
731
731
  }