Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 88 additions & 6 deletions app/admin/dsoc/projects/[id]/edit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -38,7 +40,9 @@ export default function EditProjectPage() {
const [availableMentors, setAvailableMentors] = useState<MentorOption[]>([]);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string>('');

const [galleryUploading, setGalleryUploading] = useState(false);
const [galleryError, setGalleryError] = useState('');

const [formData, setFormData] = useState({
title: '',
description: '',
Expand All @@ -60,7 +64,8 @@ export default function EditProjectPage() {
learningOutcomes: [''],
season: '2026',
status: 'draft',
featuredImage: ''
featuredImage: '',
gallery: [] as string[]
});

useEffect(() => {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -199,6 +205,35 @@ export default function EditProjectPage() {
return uploadData.url as string;
};

const handleGalleryAdd = async (e: React.ChangeEvent<HTMLInputElement>) => {
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('');
Expand Down Expand Up @@ -238,7 +273,8 @@ export default function EditProjectPage() {
season: formData.season,
status: formData.status,
featuredImage,
imageUrl: featuredImage
imageUrl: featuredImage,
gallery: formData.gallery
})
});

Expand Down Expand Up @@ -375,6 +411,52 @@ export default function EditProjectPage() {
</div>
)}
</div>

<div>
<label className="block font-bold text-sm mb-2 flex items-center gap-2">
<ImagePlus className="w-4 h-4" />
Additional Images (Gallery)
</label>
<p className="text-xs text-muted-foreground mb-2">
Optional. Shown on the project detail page below the cover.
</p>
<input
type="file"
accept="image/*"
multiple
onChange={handleGalleryAdd}
disabled={galleryUploading}
className="neo-brutal-input"
/>
{galleryUploading && (
<p className="mt-2 text-sm text-muted-foreground">Uploading...</p>
)}
{galleryError && (
<p className="mt-2 text-sm text-[var(--dsoc-pink)] font-bold">{galleryError}</p>
)}
{formData.gallery.length > 0 && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{formData.gallery.map((url, index) => (
<div key={url + index} className="relative group">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`Gallery image ${index + 1}`}
className="w-full h-28 object-cover border-4 border-[var(--dsoc-dark)]"
/>
<button
type="button"
onClick={() => handleGalleryRemove(index)}
aria-label="Remove image"
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"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>

{/* Links */}
Expand Down
81 changes: 81 additions & 0 deletions app/admin/dsoc/projects/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

const [formData, setFormData] = useState({
title: '',
Expand All @@ -54,6 +58,7 @@ export default function NewProjectPage() {
learningOutcomes: [''],
season: '2026',
featuredImage: '',
gallery: [] as string[],
});

useEffect(() => {
Expand Down Expand Up @@ -154,6 +159,35 @@ export default function NewProjectPage() {
return uploadData.url as string;
};

const handleGalleryAdd = async (e: React.ChangeEvent<HTMLInputElement>) => {
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('');
Expand Down Expand Up @@ -183,6 +217,7 @@ export default function NewProjectPage() {
learningOutcomes: formData.learningOutcomes.filter(Boolean),
featuredImage,
imageUrl: featuredImage,
gallery: formData.gallery,
status: 'draft',
}),
});
Expand Down Expand Up @@ -300,6 +335,52 @@ export default function NewProjectPage() {
</div>
)}
</div>

<div>
<label className="block font-bold text-sm mb-2 flex items-center gap-2">
<ImagePlus className="w-4 h-4" />
Additional Images (Gallery)
</label>
<p className="text-xs text-muted-foreground mb-2">
Optional. Shown on the project detail page below the cover. You can add multiple at once.
</p>
<input
type="file"
accept="image/*"
multiple
onChange={handleGalleryAdd}
disabled={galleryUploading}
className="neo-brutal-input"
/>
{galleryUploading && (
<p className="mt-2 text-sm text-muted-foreground">Uploading...</p>
)}
{galleryError && (
<p className="mt-2 text-sm text-[var(--dsoc-pink)] font-bold">{galleryError}</p>
)}
{formData.gallery.length > 0 && (
<div className="mt-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
{formData.gallery.map((url, index) => (
<div key={url + index} className="relative group">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`Gallery image ${index + 1}`}
className="w-full h-28 object-cover border-4 border-[var(--dsoc-dark)]"
/>
<button
type="button"
onClick={() => handleGalleryRemove(index)}
aria-label="Remove image"
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"
>
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
</div>

{/* Mentors */}
Expand Down
66 changes: 62 additions & 4 deletions app/dsoc/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -16,7 +16,9 @@ import {
BookOpen,
Target,
Code2,
MessageCircle
ImageIcon,
MessageCircle,
X
} from "lucide-react";
import "../../styles.css";
import DSOCNavbar from "../../components/DSOCNavbar";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean | null>(null);
const [lightboxImage, setLightboxImage] = useState<string | null>(null);

useEffect(() => {
fetchProject();
Expand Down Expand Up @@ -418,6 +423,30 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
return (
<div className="min-h-screen bg-background">
<DSOCNavbar />
{lightboxImage && (
<div
role="dialog"
aria-modal="true"
onClick={() => setLightboxImage(null)}
className="fixed inset-0 z-[200] bg-black/80 flex items-center justify-center p-4 cursor-zoom-out"
>
<button
type="button"
onClick={() => setLightboxImage(null)}
aria-label="Close image"
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"
>
<X className="w-5 h-5" />
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={lightboxImage}
alt="Project image"
className="max-h-[90vh] max-w-[90vw] object-contain border-4 border-white"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
{/* Header */}
<section className={`pt-24 pb-12 ${getDifficultyColor(project.difficulty)}`}>
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
Expand Down Expand Up @@ -508,6 +537,35 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>

{/* Gallery */}
{Array.isArray(project.gallery) && project.gallery.length > 0 && (
<div className="neo-brutal-card p-6">
<h2 className="text-xl font-black mb-4 flex items-center gap-2">
<ImageIcon className="w-6 h-6 text-[var(--dsoc-primary)]" />
Gallery
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{project.gallery.map((url, i) => (
<button
key={url + i}
type="button"
onClick={() => setLightboxImage(url)}
className="block border-4 border-[var(--dsoc-dark)] overflow-hidden hover:-translate-y-1 transition-transform"
aria-label={`Open gallery image ${i + 1}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
alt={`${project.title} image ${i + 1}`}
className="w-full h-36 sm:h-44 object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}

{/* Long Description */}
{project.longDescription && (
<div className="neo-brutal-card p-6">
Expand Down
Loading
Loading