diff --git a/app/admin/dsoc/projects/[id]/edit/page.tsx b/app/admin/dsoc/projects/[id]/edit/page.tsx index 3bc6e6b..1fc40fc 100644 --- a/app/admin/dsoc/projects/[id]/edit/page.tsx +++ b/app/admin/dsoc/projects/[id]/edit/page.tsx @@ -4,14 +4,16 @@ import Link from "next/link"; import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useParams } from "next/navigation"; -import { +import { ArrowLeft, Save, Plus, Trash2, AlertCircle, CheckCircle2, - Users + ImagePlus, + Users, + X } from "lucide-react"; import "../../../../../dsoc/styles.css"; @@ -38,7 +40,9 @@ export default function EditProjectPage() { const [availableMentors, setAvailableMentors] = useState([]); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(''); - + const [galleryUploading, setGalleryUploading] = useState(false); + const [galleryError, setGalleryError] = useState(''); + const [formData, setFormData] = useState({ title: '', description: '', @@ -60,7 +64,8 @@ export default function EditProjectPage() { learningOutcomes: [''], season: '2026', status: 'draft', - featuredImage: '' + featuredImage: '', + gallery: [] as string[] }); useEffect(() => { @@ -121,7 +126,8 @@ export default function EditProjectPage() { learningOutcomes: project.learningOutcomes && project.learningOutcomes.length > 0 ? project.learningOutcomes : [''], season: project.season || '2025', status: project.status || 'draft', - featuredImage: project.featuredImage || project.imageUrl || '' + featuredImage: project.featuredImage || project.imageUrl || '', + gallery: Array.isArray(project.gallery) ? project.gallery.filter(Boolean) : [] }); } else { setError(data.error || 'Failed to load project'); @@ -199,6 +205,35 @@ export default function EditProjectPage() { return uploadData.url as string; }; + const handleGalleryAdd = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + setGalleryError(''); + setGalleryUploading(true); + + try { + const uploaded = await Promise.all(files.map(uploadImageToCloudinary)); + setFormData((current) => ({ + ...current, + gallery: [...current.gallery, ...uploaded], + })); + } catch (err) { + console.error('Gallery upload failed:', err); + setGalleryError(err instanceof Error ? err.message : 'Failed to upload one or more images'); + } finally { + setGalleryUploading(false); + e.target.value = ''; + } + }; + + const handleGalleryRemove = (index: number) => { + setFormData((current) => ({ + ...current, + gallery: current.gallery.filter((_, i) => i !== index), + })); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -238,7 +273,8 @@ export default function EditProjectPage() { season: formData.season, status: formData.status, featuredImage, - imageUrl: featuredImage + imageUrl: featuredImage, + gallery: formData.gallery }) }); @@ -375,6 +411,52 @@ export default function EditProjectPage() { )} + +
+ +

+ Optional. Shown on the project detail page below the cover. +

+ + {galleryUploading && ( +

Uploading...

+ )} + {galleryError && ( +

{galleryError}

+ )} + {formData.gallery.length > 0 && ( +
+ {formData.gallery.map((url, index) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Gallery + +
+ ))} +
+ )} +
{/* Links */} diff --git a/app/admin/dsoc/projects/new/page.tsx b/app/admin/dsoc/projects/new/page.tsx index 39c5357..7277822 100644 --- a/app/admin/dsoc/projects/new/page.tsx +++ b/app/admin/dsoc/projects/new/page.tsx @@ -6,10 +6,12 @@ import { useRouter } from "next/navigation"; import { ArrowLeft, CheckCircle2, + ImagePlus, Plus, Save, Trash2, Users, + X, } from "lucide-react"; import "../../../../dsoc/styles.css"; @@ -32,6 +34,8 @@ export default function NewProjectPage() { const [availableMentors, setAvailableMentors] = useState([]); const [imageFile, setImageFile] = useState(null); const [imagePreview, setImagePreview] = useState(''); + const [galleryUploading, setGalleryUploading] = useState(false); + const [galleryError, setGalleryError] = useState(''); const [formData, setFormData] = useState({ title: '', @@ -54,6 +58,7 @@ export default function NewProjectPage() { learningOutcomes: [''], season: '2026', featuredImage: '', + gallery: [] as string[], }); useEffect(() => { @@ -154,6 +159,35 @@ export default function NewProjectPage() { return uploadData.url as string; }; + const handleGalleryAdd = async (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + setGalleryError(''); + setGalleryUploading(true); + + try { + const uploaded = await Promise.all(files.map(uploadImageToCloudinary)); + setFormData((current) => ({ + ...current, + gallery: [...current.gallery, ...uploaded], + })); + } catch (err) { + console.error('Gallery upload failed:', err); + setGalleryError(err instanceof Error ? err.message : 'Failed to upload one or more images'); + } finally { + setGalleryUploading(false); + e.target.value = ''; + } + }; + + const handleGalleryRemove = (index: number) => { + setFormData((current) => ({ + ...current, + gallery: current.gallery.filter((_, i) => i !== index), + })); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -183,6 +217,7 @@ export default function NewProjectPage() { learningOutcomes: formData.learningOutcomes.filter(Boolean), featuredImage, imageUrl: featuredImage, + gallery: formData.gallery, status: 'draft', }), }); @@ -300,6 +335,52 @@ export default function NewProjectPage() { )} + +
+ +

+ Optional. Shown on the project detail page below the cover. You can add multiple at once. +

+ + {galleryUploading && ( +

Uploading...

+ )} + {galleryError && ( +

{galleryError}

+ )} + {formData.gallery.length > 0 && ( +
+ {formData.gallery.map((url, index) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Gallery + +
+ ))} +
+ )} +
{/* Mentors */} diff --git a/app/dsoc/projects/[id]/page.tsx b/app/dsoc/projects/[id]/page.tsx index 14c4439..9470563 100644 --- a/app/dsoc/projects/[id]/page.tsx +++ b/app/dsoc/projects/[id]/page.tsx @@ -3,10 +3,10 @@ import Link from "next/link"; import { useState, useEffect } from "react"; import { useParams } from "next/navigation"; -import { +import { ArrowLeft, - Clock, - Users, + Clock, + Users, Calendar, Github, ExternalLink, @@ -16,7 +16,9 @@ import { BookOpen, Target, Code2, - MessageCircle + ImageIcon, + MessageCircle, + X } from "lucide-react"; import "../../styles.css"; import DSOCNavbar from "../../components/DSOCNavbar"; @@ -58,6 +60,8 @@ interface Project { milestones: { title: string; description: string; dueDate: string; completed: boolean }[]; discordChannelId?: string; season: string; + featuredImage?: string; + gallery?: string[]; } // Sample projects for fallback when API is unavailable @@ -295,6 +299,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [isMentee, setIsMentee] = useState(null); + const [lightboxImage, setLightboxImage] = useState(null); useEffect(() => { fetchProject(); @@ -418,6 +423,30 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st return (
+ {lightboxImage && ( +
setLightboxImage(null)} + className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4 cursor-zoom-out" + > + + {/* eslint-disable-next-line @next/next/no-img-element */} + Project image e.stopPropagation()} + /> +
+ )} {/* Header */}
@@ -508,6 +537,35 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
+ {/* Gallery */} + {Array.isArray(project.gallery) && project.gallery.length > 0 && ( +
+

+ + Gallery +

+
+ {project.gallery.map((url, i) => ( + + ))} +
+
+ )} + {/* Long Description */} {project.longDescription && (
diff --git a/models/DSOCProject.ts b/models/DSOCProject.ts index 337402c..466f9d1 100644 --- a/models/DSOCProject.ts +++ b/models/DSOCProject.ts @@ -30,6 +30,7 @@ export interface IDSOCProject extends Document { discordChannelId?: string; discordRoleId?: string; featuredImage?: string; + gallery: string[]; isActive: boolean; season: string; // e.g., "2025", "Summer 2025" createdAt: Date; @@ -143,6 +144,10 @@ const DSOCProjectSchema = new Schema( type: String, trim: true, }, + gallery: { + type: [String], + default: [], + }, isActive: { type: Boolean, default: true,