From e60655bbaacc8094c42b9fdda51ed79baf61b4f8 Mon Sep 17 00:00:00 2001 From: subu19 Date: Fri, 24 Apr 2026 14:23:16 +0545 Subject: [PATCH 1/2] feat: dynamic guide sections, extended gallery management API, and flexible icon support in UI components --- src/app/app.tsx | 4 +- .../cms-gallery/api/use-cms-gallery.ts | 60 ++- .../cms-gallery/ui/gallery-manager.tsx | 370 +++++++++++++--- src/features/cms-guide/api/use-cms-guide.ts | 46 +- src/features/cms-guide/ui/guide-manager.tsx | 195 ++++++++- src/features/guides/hooks/use-guide.ts | 21 +- src/pages/gallery/gallery.tsx | 33 +- src/pages/guides/guides.tsx | 4 +- src/pages/guides/ui/guides-content.tsx | 411 ++++-------------- src/pages/guides/ui/guides-quick-nav.tsx | 66 +-- src/pages/guides/ui/section-header.tsx | 15 +- src/shared/api/client.ts | 2 + src/shared/types/index.ts | 10 +- 13 files changed, 755 insertions(+), 482 deletions(-) diff --git a/src/app/app.tsx b/src/app/app.tsx index 5b63d87..8833e54 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -3,7 +3,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "../shared/config/queryClient"; import { SocketProvider } from "../shared/providers/socket-provider"; import { AuthProvider } from "../shared/providers/auth-provider"; -import { TooltipProvider } from "../shared/ui"; +import { TooltipProvider, Toaster } from "../shared/ui"; import "./styles/index.css"; import MainLayout from "../widgets/layout/main-layout"; @@ -24,6 +24,7 @@ function App() { + }> @@ -33,6 +34,7 @@ function App() { } /> } /> } /> + } /> } /> }> diff --git a/src/features/cms-gallery/api/use-cms-gallery.ts b/src/features/cms-gallery/api/use-cms-gallery.ts index 8daaa8b..3f2dc81 100644 --- a/src/features/cms-gallery/api/use-cms-gallery.ts +++ b/src/features/cms-gallery/api/use-cms-gallery.ts @@ -9,7 +9,7 @@ export const useCmsGallery = () => { mutationFn: async ({ season, photos }: { season: string; photos: File[] }) => { const formData = new FormData(); photos.forEach((photo) => formData.append('photos', photo)); - const response = await api.post(`/add/gallery/${season}`, formData); + const response = await api.post(`/add/gallery/${encodeURIComponent(season)}`, formData); return response.data; }, onSuccess: () => { @@ -27,7 +27,7 @@ export const useCmsGallery = () => { // According to backend: apiRouter.post("/delete/gallery/:season/:photo", verify, handleGalleryDelete); // We need to encode the key if it contains slashes const encodedKey = encodeURIComponent(photoKey); - const response = await api.post(`/delete/gallery/${season}/${encodedKey}`); + const response = await api.post(`/delete/gallery/${encodeURIComponent(season)}/${encodedKey}`); return response.data; }, onSuccess: () => { @@ -39,10 +39,66 @@ export const useCmsGallery = () => { }, }); + const createSeasonMutation = useMutation({ + mutationFn: async ({ season, cover }: { season: string; cover: File | null }) => { + const formData = new FormData(); + formData.append('season', season); + if (cover) { + formData.append('cover', cover); + } + const response = await api.post('/gallery/season', formData); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gallery'] }); + toast.success('Season created successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to create season'); + }, + }); + + const deleteSeasonMutation = useMutation({ + mutationFn: async (season: string) => { + const response = await api.delete(`/gallery/season/${encodeURIComponent(season)}`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gallery'] }); + toast.success('Season deleted successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to delete season'); + }, + }); + + const updateSeasonMutation = useMutation({ + mutationFn: async ({ oldSeason, newSeason, cover }: { oldSeason: string; newSeason?: string; cover?: File | null }) => { + const formData = new FormData(); + if (newSeason) formData.append('season', newSeason); + if (cover) formData.append('cover', cover); + const response = await api.patch(`/gallery/season/${encodeURIComponent(oldSeason)}`, formData); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gallery'] }); + toast.success('Season updated successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to update season'); + }, + }); + return { addPhotos: addPhotosMutation.mutateAsync, isAdding: addPhotosMutation.isPending, deletePhoto: deletePhotoMutation.mutateAsync, isDeleting: deletePhotoMutation.isPending, + createSeason: createSeasonMutation.mutateAsync, + isCreatingSeason: createSeasonMutation.isPending, + deleteSeason: deleteSeasonMutation.mutateAsync, + isDeletingSeason: deleteSeasonMutation.isPending, + updateSeason: updateSeasonMutation.mutateAsync, + isUpdatingSeason: updateSeasonMutation.isPending, }; }; diff --git a/src/features/cms-gallery/ui/gallery-manager.tsx b/src/features/cms-gallery/ui/gallery-manager.tsx index 74b875d..88f5389 100644 --- a/src/features/cms-gallery/ui/gallery-manager.tsx +++ b/src/features/cms-gallery/ui/gallery-manager.tsx @@ -21,15 +21,43 @@ import { SelectTrigger, SelectValue, } from '../../../shared/ui'; -import { Trash2, Image as ImageIcon, Loader2, UploadCloud, Folder } from 'lucide-react'; +import { Trash2, Image as ImageIcon, Loader2, UploadCloud, Folder, Pencil } from 'lucide-react'; export const GalleryManager = () => { const { data: gallery, isLoading } = useGallery(); - const { addPhotos, isAdding, deletePhoto, isDeleting } = useCmsGallery(); + const { + addPhotos, + isAdding, + deletePhoto, + isDeleting, + createSeason, + isCreatingSeason, + deleteSeason, + isDeletingSeason, + updateSeason, + isUpdatingSeason + } = useCmsGallery(); + const [selectedFiles, setSelectedFiles] = useState(null); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [currentSeason, setCurrentSeason] = useState(''); + // New state for creating seasons + const [isCreateSeasonOpen, setIsCreateSeasonOpen] = useState(false); + const [newSeasonName, setNewSeasonName] = useState(''); + const [newSeasonCover, setNewSeasonCover] = useState(null); + + // New state for editing seasons + const [isEditSeasonOpen, setIsEditSeasonOpen] = useState(false); + const [editingSeason, setEditingSeason] = useState(null); + const [editSeasonName, setEditSeasonName] = useState(''); + const [editSeasonCover, setEditSeasonCover] = useState(null); + + // New state for deleting seasons (strict) + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [seasonToDelete, setSeasonToDelete] = useState(null); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(''); + const handleAddPhotos = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedFiles || !currentSeason) return; @@ -46,6 +74,67 @@ export const GalleryManager = () => { } }; + const handleCreateSeason = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newSeasonName) return; + + try { + await createSeason({ + season: newSeasonName, + cover: newSeasonCover + }); + setIsCreateSeasonOpen(false); + setNewSeasonName(''); + setNewSeasonCover(null); + } catch (err) { + // Error handled by hook toast + } + }; + + const handleUpdateSeason = async (e: React.FormEvent) => { + e.preventDefault(); + if (!editingSeason || !editSeasonName) return; + + try { + await updateSeason({ + oldSeason: editingSeason, + newSeason: editSeasonName, + cover: editSeasonCover + }); + setIsEditSeasonOpen(false); + setEditingSeason(null); + setEditSeasonName(''); + setEditSeasonCover(null); + } catch (err) { + // Error handled by hook toast + } + }; + + const openEditDialog = (season: any) => { + setEditingSeason(season.title); + setEditSeasonName(season.title); + setIsEditSeasonOpen(true); + }; + + const openDeleteConfirm = (seasonTitle: string) => { + setSeasonToDelete(seasonTitle); + setDeleteConfirmInput(''); + setIsDeleteConfirmOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!seasonToDelete || deleteConfirmInput !== seasonToDelete) return; + + try { + await deleteSeason(seasonToDelete); + setIsDeleteConfirmOpen(false); + setSeasonToDelete(null); + setDeleteConfirmInput(''); + } catch (err) { + // Error handled by hook toast + } + }; + if (isLoading) { return (
@@ -57,63 +146,204 @@ export const GalleryManager = () => { return (
-
+
{gallery?.reduce((acc, curr) => acc + curr.photos.length, 0) || 0} Total Photos Uploaded
- - - - - - - Upload Photos - -
-
- - -
+ +
+ {/* Create Season Dialog */} + + + + + + + Create New Season + + +
+ + setNewSeasonName(e.target.value)} + required + className="bg-white/5 border-white/10 text-white" + /> +
+ +
+ +
+ setNewSeasonCover(e.target.files?.[0] || null)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +

+ {newSeasonCover ? newSeasonCover.name : "Choose a cover image"} +

+
+
+ + + +
+
-
- -
+ {/* Edit Season Dialog */} + + + + Edit Season + +
+
+ setSelectedFiles(e.target.files)} + id="editSeasonName" + placeholder="e.g. Season 6, Event Name" + value={editSeasonName} + onChange={(e) => setEditSeasonName(e.target.value)} required - className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + className="bg-white/5 border-white/10 text-white" + /> +
+ +
+ +
+ setEditSeasonCover(e.target.files?.[0] || null)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +

+ {editSeasonCover ? editSeasonCover.name : "Upload new cover"} +

+
+

Leaving this empty will keep the current cover.

+
+ + +
+
+
+ + {/* Strict Delete Confirmation Dialog */} + + + + Extreme Caution! + +
+
+ You are about to delete "{seasonToDelete}". + This will archive the season and all its photos. They will no longer be visible on the public site. +
+ +
+ + setDeleteConfirmInput(e.target.value)} + className="bg-white/5 border-white/10 text-white focus:border-red-500/50" /> - -

Click to browse or drag & drop

-

- {selectedFiles ? `${selectedFiles.length} files selected` : "PNG, JPG, WEBP up to 10MB"} -

+
+ +
+ +
- -
+ + {/* Upload Photos Dialog */} + + + - - - + + + + Upload Photos + +
+
+ + +
+ +
+ +
+ setSelectedFiles(e.target.files)} + required + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +

Click to browse or drag & drop

+

+ {selectedFiles ? `${selectedFiles.length} files selected` : "PNG, JPG, WEBP up to 10MB"} +

+
+
+ + +
+
+
+
@@ -121,13 +351,43 @@ export const GalleryManager = () => {
-
- +
+ {season.cover ? ( + + ) : ( + + )} + +
+
+ {season.title} +

Created on {new Date(season.photos[0]?.uploadedAt || Date.now()).toLocaleDateString()}

- {season.title}
-
- {season.photos.length} photos +
+
+ {season.photos.length} photos +
+
+ + +
@@ -169,7 +429,7 @@ export const GalleryManager = () => {

No Seasons Found

- The gallery structure is empty. Make sure seasons are created in the backend. + The gallery structure is empty. Create your first season to get started.

)} diff --git a/src/features/cms-guide/api/use-cms-guide.ts b/src/features/cms-guide/api/use-cms-guide.ts index a6be3eb..fe74133 100644 --- a/src/features/cms-guide/api/use-cms-guide.ts +++ b/src/features/cms-guide/api/use-cms-guide.ts @@ -13,15 +13,40 @@ export const useCmsGuide = () => { }); const saveGuideMutation = useMutation({ - mutationFn: async ({ id, header, data, image }: { id: string; header: string; data: any[]; image?: File }) => { + mutationFn: async ({ + id, + header, + data, + image, + icon, + removeImage, + removeIcon + }: { + id: string; + header: string; + data: any[]; + image?: File; + icon?: File; + removeImage?: boolean; + removeIcon?: boolean; + }) => { const formData = new FormData(); formData.append('header', header); formData.append('data', JSON.stringify(data)); if (image) { formData.append('image', image); } + if (icon) { + formData.append('icon', icon); + } + if (removeImage) { + formData.append('removeImage', 'true'); + } + if (removeIcon) { + formData.append('removeIcon', 'true'); + } - const response = await api.post(`/guide/${id}`, formData); + const response = await api.post(`/guide/${encodeURIComponent(id)}`, formData); return response.data; }, onSuccess: () => { @@ -34,10 +59,27 @@ export const useCmsGuide = () => { }, }); + const deleteGuideMutation = useMutation({ + mutationFn: async (id: string) => { + const response = await api.delete(`/guide/${encodeURIComponent(id)}`); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['guides-all'] }); + queryClient.invalidateQueries({ queryKey: ['guides'] }); + toast.success('Guide deleted successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to delete guide'); + }, + }); + return { guides, isLoading, saveGuide: saveGuideMutation.mutateAsync, isSaving: saveGuideMutation.isPending, + deleteGuide: deleteGuideMutation.mutateAsync, + isDeleting: deleteGuideMutation.isPending, }; }; diff --git a/src/features/cms-guide/ui/guide-manager.tsx b/src/features/cms-guide/ui/guide-manager.tsx index aa689fe..abb96a9 100644 --- a/src/features/cms-guide/ui/guide-manager.tsx +++ b/src/features/cms-guide/ui/guide-manager.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { useCmsGuide } from '../api/use-cms-guide'; import { Button, @@ -18,6 +18,11 @@ import { AccordionContent, AccordionItem, AccordionTrigger, + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogTitle, } from '../../../shared/ui'; import { Plus, Trash2, Loader2, Save, FileImage } from 'lucide-react'; import type { GuideSection, GuideItem } from '@/shared/types'; @@ -25,11 +30,25 @@ import MDEditor from '@uiw/react-md-editor'; import { MarkdownContent } from '@/shared/ui/components/markdown-content'; export const GuideManager = () => { - const { guides, isLoading, saveGuide, isSaving } = useCmsGuide(); + const { guides, isLoading, saveGuide, isSaving, deleteGuide, isDeleting } = useCmsGuide(); const [selectedGuideId, setSelectedGuideId] = useState(''); const [editingGuide, setEditingGuide] = useState | null>(null); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); + const [selectedIcon, setSelectedIcon] = useState(null); + const [iconPreview, setIconPreview] = useState(null); + + // File removal state + const [removeImage, setRemoveImage] = useState(false); + const [removeIcon, setRemoveIcon] = useState(false); + + // File input refs for resetting + const imageInputRef = useRef(null); + const iconInputRef = useRef(null); + + // Deletion state + const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(''); // Set initial guide useEffect(() => { @@ -53,6 +72,14 @@ export const GuideManager = () => { } setSelectedImage(null); setImagePreview(null); + setSelectedIcon(null); + setIconPreview(null); + setRemoveImage(false); + setRemoveIcon(false); + + // Reset file inputs + if (imageInputRef.current) imageInputRef.current.value = ''; + if (iconInputRef.current) iconInputRef.current.value = ''; } }, [selectedGuideId, guides]); @@ -67,6 +94,17 @@ export const GuideManager = () => { } }; + const handleIconChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + setSelectedIcon(file); + setIconPreview(URL.createObjectURL(file)); + } else { + setSelectedIcon(null); + setIconPreview(null); + } + }; + const handleSave = async (e: React.FormEvent) => { e.preventDefault(); if (!editingGuide || !editingGuide.id || !editingGuide.header) return; @@ -77,13 +115,31 @@ export const GuideManager = () => { header: editingGuide.header, data: editingGuide.data || [], image: selectedImage || undefined, + icon: selectedIcon || undefined, + removeImage, + removeIcon, }); + + // Reset removal flags after save + setRemoveImage(false); + setRemoveIcon(false); // Error is handled by hook } catch (err) { // Error handled by hook } }; + const handleDelete = async () => { + if (!selectedGuideId || selectedGuideId === 'new' || deleteConfirmInput !== selectedGuideId) return; + + try { + await deleteGuide(selectedGuideId); + setIsDeleteConfirmOpen(false); + setDeleteConfirmInput(''); + setSelectedGuideId(guides?.[0]?.id || ''); + } catch (err) { } + }; + const addDataItem = () => { if (!editingGuide) return; const newData = [...(editingGuide.data || []), { title: '', text: '' }]; @@ -116,7 +172,7 @@ export const GuideManager = () => {

Guide Management

- +
+ + {selectedGuideId && selectedGuideId !== 'new' && ( + + + + + + + Delete Guide? + +
+
+ Are you sure you want to delete "{selectedGuideId}"? This action can be undone by an admin in the database, but it will be hidden from everyone else. +
+ +
+ + setDeleteConfirmInput(e.target.value)} + className="bg-white/5 border-white/10 text-white focus:border-red-500/50" + /> +
+ +
+ + +
+
+
+
+ )}
@@ -176,15 +276,69 @@ export const GuideManager = () => { />
- +
+ + {(selectedImage || (editingGuide.image && !removeImage)) && ( + + )} +
+
+
+ + {(selectedIcon || (editingGuide.icon && !removeIcon)) && ( + + )} +
+ +
@@ -220,7 +374,7 @@ export const GuideManager = () => { > - +
{ Live Preview - - Read-only - +
+ {(iconPreview || (editingGuide.icon && !removeIcon)) ? ( + Icon + ) : null} + + Read-only + +
- + {/* This mimics the frontend guides-content.tsx layout styling */} {/* Simulated frontend background/container */}
- + {/* Header Preview */} - {(editingGuide.header || imagePreview || editingGuide.image) && ( + {(editingGuide.header || imagePreview || (editingGuide.image && !removeImage)) && (
{editingGuide.header && ( )} - - {(imagePreview || editingGuide.image) && ( - Cover )} diff --git a/src/features/guides/hooks/use-guide.ts b/src/features/guides/hooks/use-guide.ts index f453979..453961b 100644 --- a/src/features/guides/hooks/use-guide.ts +++ b/src/features/guides/hooks/use-guide.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import type { UseQueryResult } from '@tanstack/react-query'; import { api } from '../../../shared/api/client'; -import type { GuidesData } from '../../../shared/types'; +import type { GuideSection } from '../../../shared/types'; interface GuideResponse { err: boolean; @@ -19,27 +19,12 @@ interface GuideResponse { }>; } -export const useGuides = (): UseQueryResult => { +export const useGuides = (): UseQueryResult => { return useQuery({ queryKey: ['guides'], queryFn: async () => { const { data } = await api.get('/guides'); - - // Transform the array response into organized sections - const guidesMap: Partial = {}; - - data.data.forEach((section) => { - const key = section.id as keyof GuidesData; - guidesMap[key] = { - _id: section._id, - id: section.id, - header: section.header, - data: section.data, - image: section.image, - }; - }); - - return guidesMap as GuidesData; + return data.data as GuideSection[]; }, }); }; diff --git a/src/pages/gallery/gallery.tsx b/src/pages/gallery/gallery.tsx index ac1e28a..3785ade 100644 --- a/src/pages/gallery/gallery.tsx +++ b/src/pages/gallery/gallery.tsx @@ -12,16 +12,25 @@ import { } from '../../shared/ui'; import { stagger, fadeUp, scaleIn } from '../../shared/lib/framer-motion/variants'; +import { useParams, useNavigate } from 'react-router-dom'; + const Gallery = () => { + const { season: seasonParam } = useParams(); + const navigate = useNavigate(); const { data: galleryData, isLoading, error } = useGallery(); - const [selectedIndex, setSelectedIndex] = useState(0); + + const selectedIndex = useMemo(() => { + if (!galleryData || !seasonParam) return 0; + const index = galleryData.findIndex(s => s.title.toLowerCase() === seasonParam.toLowerCase()); + return index === -1 ? 0 : index; + }, [galleryData, seasonParam]); - // Set initial selected season when data loads + // Handle initial redirect to the first season's URL if no season in URL React.useEffect(() => { - if (galleryData && galleryData.length > 0) { - setSelectedIndex(0); + if (galleryData && galleryData.length > 0 && !seasonParam) { + navigate(`/gallery/${galleryData[0].title}`, { replace: true }); } - }, [galleryData]); + }, [galleryData, seasonParam, navigate]); const currentSeason = useMemo(() => { return galleryData?.[selectedIndex] || null; @@ -29,13 +38,15 @@ const Gallery = () => { const goToPrev = useCallback(() => { if (!galleryData) return; - setSelectedIndex(i => (i - 1 + galleryData.length) % galleryData.length); - }, [galleryData]); + const prevIndex = (selectedIndex - 1 + galleryData.length) % galleryData.length; + navigate(`/gallery/${galleryData[prevIndex].title}`); + }, [galleryData, selectedIndex, navigate]); const goToNext = useCallback(() => { if (!galleryData) return; - setSelectedIndex(i => (i + 1) % galleryData.length); - }, [galleryData]); + const nextIndex = (selectedIndex + 1) % galleryData.length; + navigate(`/gallery/${galleryData[nextIndex].title}`); + }, [galleryData, selectedIndex, navigate]); if (isLoading) return ; if (error) return ; @@ -145,7 +156,7 @@ const Gallery = () => { }} exit={{ opacity: 0, scale: 0.85 }} transition={{ type: 'spring', stiffness: 340, damping: 30 }} - onClick={() => setSelectedIndex(idx)} + onClick={() => navigate(`/gallery/${galleryData![idx].title}`)} className={` relative overflow-hidden rounded-[2.5rem] border-2 shadow-2xl transition-[border-color,box-shadow,filter] duration-500 group @@ -209,7 +220,7 @@ const Gallery = () => { {galleryData?.map((_, i) => (