Skip to content

Commit c7796bf

Browse files
Merge pull request #45 from devweekends/dsoc-project-gallery
feat(dsoc): support multiple images per project
2 parents d55d946 + 4900a05 commit c7796bf

4 files changed

Lines changed: 236 additions & 10 deletions

File tree

app/admin/dsoc/projects/[id]/edit/page.tsx

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import Link from "next/link";
44
import { useState, useEffect } from "react";
55
import { useRouter } from "next/navigation";
66
import { useParams } from "next/navigation";
7-
import {
7+
import {
88
ArrowLeft,
99
Save,
1010
Plus,
1111
Trash2,
1212
AlertCircle,
1313
CheckCircle2,
14-
Users
14+
ImagePlus,
15+
Users,
16+
X
1517
} from "lucide-react";
1618
import "../../../../../dsoc/styles.css";
1719

@@ -38,7 +40,9 @@ export default function EditProjectPage() {
3840
const [availableMentors, setAvailableMentors] = useState<MentorOption[]>([]);
3941
const [imageFile, setImageFile] = useState<File | null>(null);
4042
const [imagePreview, setImagePreview] = useState<string>('');
41-
43+
const [galleryUploading, setGalleryUploading] = useState(false);
44+
const [galleryError, setGalleryError] = useState('');
45+
4246
const [formData, setFormData] = useState({
4347
title: '',
4448
description: '',
@@ -60,7 +64,8 @@ export default function EditProjectPage() {
6064
learningOutcomes: [''],
6165
season: '2026',
6266
status: 'draft',
63-
featuredImage: ''
67+
featuredImage: '',
68+
gallery: [] as string[]
6469
});
6570

6671
useEffect(() => {
@@ -121,7 +126,8 @@ export default function EditProjectPage() {
121126
learningOutcomes: project.learningOutcomes && project.learningOutcomes.length > 0 ? project.learningOutcomes : [''],
122127
season: project.season || '2025',
123128
status: project.status || 'draft',
124-
featuredImage: project.featuredImage || project.imageUrl || ''
129+
featuredImage: project.featuredImage || project.imageUrl || '',
130+
gallery: Array.isArray(project.gallery) ? project.gallery.filter(Boolean) : []
125131
});
126132
} else {
127133
setError(data.error || 'Failed to load project');
@@ -199,6 +205,35 @@ export default function EditProjectPage() {
199205
return uploadData.url as string;
200206
};
201207

208+
const handleGalleryAdd = async (e: React.ChangeEvent<HTMLInputElement>) => {
209+
const files = Array.from(e.target.files || []);
210+
if (files.length === 0) return;
211+
212+
setGalleryError('');
213+
setGalleryUploading(true);
214+
215+
try {
216+
const uploaded = await Promise.all(files.map(uploadImageToCloudinary));
217+
setFormData((current) => ({
218+
...current,
219+
gallery: [...current.gallery, ...uploaded],
220+
}));
221+
} catch (err) {
222+
console.error('Gallery upload failed:', err);
223+
setGalleryError(err instanceof Error ? err.message : 'Failed to upload one or more images');
224+
} finally {
225+
setGalleryUploading(false);
226+
e.target.value = '';
227+
}
228+
};
229+
230+
const handleGalleryRemove = (index: number) => {
231+
setFormData((current) => ({
232+
...current,
233+
gallery: current.gallery.filter((_, i) => i !== index),
234+
}));
235+
};
236+
202237
const handleSubmit = async (e: React.FormEvent) => {
203238
e.preventDefault();
204239
setError('');
@@ -238,7 +273,8 @@ export default function EditProjectPage() {
238273
season: formData.season,
239274
status: formData.status,
240275
featuredImage,
241-
imageUrl: featuredImage
276+
imageUrl: featuredImage,
277+
gallery: formData.gallery
242278
})
243279
});
244280

@@ -375,6 +411,52 @@ export default function EditProjectPage() {
375411
</div>
376412
)}
377413
</div>
414+
415+
<div>
416+
<label className="block font-bold text-sm mb-2 flex items-center gap-2">
417+
<ImagePlus className="w-4 h-4" />
418+
Additional Images (Gallery)
419+
</label>
420+
<p className="text-xs text-muted-foreground mb-2">
421+
Optional. Shown on the project detail page below the cover.
422+
</p>
423+
<input
424+
type="file"
425+
accept="image/*"
426+
multiple
427+
onChange={handleGalleryAdd}
428+
disabled={galleryUploading}
429+
className="neo-brutal-input"
430+
/>
431+
{galleryUploading && (
432+
<p className="mt-2 text-sm text-muted-foreground">Uploading...</p>
433+
)}
434+
{galleryError && (
435+
<p className="mt-2 text-sm text-[var(--dsoc-pink)] font-bold">{galleryError}</p>
436+
)}
437+
{formData.gallery.length > 0 && (
438+
<div className="mt-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
439+
{formData.gallery.map((url, index) => (
440+
<div key={url + index} className="relative group">
441+
{/* eslint-disable-next-line @next/next/no-img-element */}
442+
<img
443+
src={url}
444+
alt={`Gallery image ${index + 1}`}
445+
className="w-full h-28 object-cover border-4 border-[var(--dsoc-dark)]"
446+
/>
447+
<button
448+
type="button"
449+
onClick={() => handleGalleryRemove(index)}
450+
aria-label="Remove image"
451+
className="absolute -top-2 -right-2 w-7 h-7 bg-[var(--dsoc-pink)] text-white border-4 border-[var(--dsoc-dark)] flex items-center justify-center"
452+
>
453+
<X className="w-3 h-3" />
454+
</button>
455+
</div>
456+
))}
457+
</div>
458+
)}
459+
</div>
378460
</div>
379461

380462
{/* Links */}

app/admin/dsoc/projects/new/page.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import { useRouter } from "next/navigation";
66
import {
77
ArrowLeft,
88
CheckCircle2,
9+
ImagePlus,
910
Plus,
1011
Save,
1112
Trash2,
1213
Users,
14+
X,
1315
} from "lucide-react";
1416
import "../../../../dsoc/styles.css";
1517

@@ -32,6 +34,8 @@ export default function NewProjectPage() {
3234
const [availableMentors, setAvailableMentors] = useState<MentorOption[]>([]);
3335
const [imageFile, setImageFile] = useState<File | null>(null);
3436
const [imagePreview, setImagePreview] = useState<string>('');
37+
const [galleryUploading, setGalleryUploading] = useState(false);
38+
const [galleryError, setGalleryError] = useState('');
3539

3640
const [formData, setFormData] = useState({
3741
title: '',
@@ -54,6 +58,7 @@ export default function NewProjectPage() {
5458
learningOutcomes: [''],
5559
season: '2026',
5660
featuredImage: '',
61+
gallery: [] as string[],
5762
});
5863

5964
useEffect(() => {
@@ -154,6 +159,35 @@ export default function NewProjectPage() {
154159
return uploadData.url as string;
155160
};
156161

162+
const handleGalleryAdd = async (e: React.ChangeEvent<HTMLInputElement>) => {
163+
const files = Array.from(e.target.files || []);
164+
if (files.length === 0) return;
165+
166+
setGalleryError('');
167+
setGalleryUploading(true);
168+
169+
try {
170+
const uploaded = await Promise.all(files.map(uploadImageToCloudinary));
171+
setFormData((current) => ({
172+
...current,
173+
gallery: [...current.gallery, ...uploaded],
174+
}));
175+
} catch (err) {
176+
console.error('Gallery upload failed:', err);
177+
setGalleryError(err instanceof Error ? err.message : 'Failed to upload one or more images');
178+
} finally {
179+
setGalleryUploading(false);
180+
e.target.value = '';
181+
}
182+
};
183+
184+
const handleGalleryRemove = (index: number) => {
185+
setFormData((current) => ({
186+
...current,
187+
gallery: current.gallery.filter((_, i) => i !== index),
188+
}));
189+
};
190+
157191
const handleSubmit = async (e: React.FormEvent) => {
158192
e.preventDefault();
159193
setError('');
@@ -183,6 +217,7 @@ export default function NewProjectPage() {
183217
learningOutcomes: formData.learningOutcomes.filter(Boolean),
184218
featuredImage,
185219
imageUrl: featuredImage,
220+
gallery: formData.gallery,
186221
status: 'draft',
187222
}),
188223
});
@@ -300,6 +335,52 @@ export default function NewProjectPage() {
300335
</div>
301336
)}
302337
</div>
338+
339+
<div>
340+
<label className="block font-bold text-sm mb-2 flex items-center gap-2">
341+
<ImagePlus className="w-4 h-4" />
342+
Additional Images (Gallery)
343+
</label>
344+
<p className="text-xs text-muted-foreground mb-2">
345+
Optional. Shown on the project detail page below the cover. You can add multiple at once.
346+
</p>
347+
<input
348+
type="file"
349+
accept="image/*"
350+
multiple
351+
onChange={handleGalleryAdd}
352+
disabled={galleryUploading}
353+
className="neo-brutal-input"
354+
/>
355+
{galleryUploading && (
356+
<p className="mt-2 text-sm text-muted-foreground">Uploading...</p>
357+
)}
358+
{galleryError && (
359+
<p className="mt-2 text-sm text-[var(--dsoc-pink)] font-bold">{galleryError}</p>
360+
)}
361+
{formData.gallery.length > 0 && (
362+
<div className="mt-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
363+
{formData.gallery.map((url, index) => (
364+
<div key={url + index} className="relative group">
365+
{/* eslint-disable-next-line @next/next/no-img-element */}
366+
<img
367+
src={url}
368+
alt={`Gallery image ${index + 1}`}
369+
className="w-full h-28 object-cover border-4 border-[var(--dsoc-dark)]"
370+
/>
371+
<button
372+
type="button"
373+
onClick={() => handleGalleryRemove(index)}
374+
aria-label="Remove image"
375+
className="absolute -top-2 -right-2 w-7 h-7 bg-[var(--dsoc-pink)] text-white border-4 border-[var(--dsoc-dark)] flex items-center justify-center"
376+
>
377+
<X className="w-3 h-3" />
378+
</button>
379+
</div>
380+
))}
381+
</div>
382+
)}
383+
</div>
303384
</div>
304385

305386
{/* Mentors */}

app/dsoc/projects/[id]/page.tsx

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
import Link from "next/link";
44
import { useState, useEffect } from "react";
55
import { useParams } from "next/navigation";
6-
import {
6+
import {
77
ArrowLeft,
8-
Clock,
9-
Users,
8+
Clock,
9+
Users,
1010
Calendar,
1111
Github,
1212
ExternalLink,
@@ -16,7 +16,9 @@ import {
1616
BookOpen,
1717
Target,
1818
Code2,
19-
MessageCircle
19+
ImageIcon,
20+
MessageCircle,
21+
X
2022
} from "lucide-react";
2123
import "../../styles.css";
2224
import DSOCNavbar from "../../components/DSOCNavbar";
@@ -58,6 +60,8 @@ interface Project {
5860
milestones: { title: string; description: string; dueDate: string; completed: boolean }[];
5961
discordChannelId?: string;
6062
season: string;
63+
featuredImage?: string;
64+
gallery?: string[];
6165
}
6266

6367
// Sample projects for fallback when API is unavailable
@@ -295,6 +299,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
295299
const [loading, setLoading] = useState(true);
296300
const [error, setError] = useState('');
297301
const [isMentee, setIsMentee] = useState<boolean | null>(null);
302+
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
298303

299304
useEffect(() => {
300305
fetchProject();
@@ -418,6 +423,30 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
418423
return (
419424
<div className="min-h-screen bg-background">
420425
<DSOCNavbar />
426+
{lightboxImage && (
427+
<div
428+
role="dialog"
429+
aria-modal="true"
430+
onClick={() => setLightboxImage(null)}
431+
className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4 cursor-zoom-out"
432+
>
433+
<button
434+
type="button"
435+
onClick={() => setLightboxImage(null)}
436+
aria-label="Close image"
437+
className="absolute top-4 right-4 w-10 h-10 bg-white text-[var(--dsoc-dark)] border-4 border-[var(--dsoc-dark)] flex items-center justify-center"
438+
>
439+
<X className="w-5 h-5" />
440+
</button>
441+
{/* eslint-disable-next-line @next/next/no-img-element */}
442+
<img
443+
src={lightboxImage}
444+
alt="Project image"
445+
className="max-h-[90vh] max-w-[90vw] object-contain border-4 border-white"
446+
onClick={(e) => e.stopPropagation()}
447+
/>
448+
</div>
449+
)}
421450
{/* Header */}
422451
<section className={`pt-24 pb-12 ${getDifficultyColor(project.difficulty)}`}>
423452
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
@@ -508,6 +537,35 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
508537
</div>
509538
</div>
510539

540+
{/* Gallery */}
541+
{Array.isArray(project.gallery) && project.gallery.length > 0 && (
542+
<div className="neo-brutal-card p-6">
543+
<h2 className="text-xl font-black mb-4 flex items-center gap-2">
544+
<ImageIcon className="w-6 h-6 text-[var(--dsoc-primary)]" />
545+
Gallery
546+
</h2>
547+
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
548+
{project.gallery.map((url, i) => (
549+
<button
550+
key={url + i}
551+
type="button"
552+
onClick={() => setLightboxImage(url)}
553+
className="block border-4 border-[var(--dsoc-dark)] overflow-hidden hover:-translate-y-1 transition-transform"
554+
aria-label={`Open gallery image ${i + 1}`}
555+
>
556+
{/* eslint-disable-next-line @next/next/no-img-element */}
557+
<img
558+
src={url}
559+
alt={`${project.title} image ${i + 1}`}
560+
className="w-full h-36 sm:h-44 object-cover"
561+
loading="lazy"
562+
/>
563+
</button>
564+
))}
565+
</div>
566+
</div>
567+
)}
568+
511569
{/* Long Description */}
512570
{project.longDescription && (
513571
<div className="neo-brutal-card p-6">

0 commit comments

Comments
 (0)