Skip to content

Commit 4032beb

Browse files
feat: add community projects and testimonials pages
- Add Projects page with submission and admin approval workflow - Add Testimonials page with support for text and video (YouTube) - Add Admin Dashboard management for Projects and Testimonials - Update Navbar to include Community dropdown - Update Footer with Community links - Add Project and Testimonial Mongoose models - Configure Next.js image patterns for external sources
1 parent 077540e commit 4032beb

File tree

24 files changed

+2686
-9
lines changed

24 files changed

+2686
-9
lines changed

app/admin/dashboard/layout.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import React, { useState } from 'react';
44
import Link from 'next/link';
55
import { usePathname } from 'next/navigation';
6-
import { LogOut, Menu, X, LayoutDashboard, Users, Users2, CalendarDays, Network, Globe, BookOpen, Tag, Briefcase } from 'lucide-react';
6+
import { LogOut, Menu, X, LayoutDashboard, Users, Users2, CalendarDays, Network, Globe, BookOpen, Tag, Briefcase, Rocket, MessageSquareQuote } from 'lucide-react';
77
import { Button } from '@/components/ui/button';
88

99
const menuItems = [
@@ -17,6 +17,8 @@ const menuItems = [
1717
{ title: 'Tags', href: '/admin/dashboard/tags', icon: <Tag className="w-5 h-5" /> },
1818
{ title: 'MindMaster', href: '/admin/dashboard/mindmaster', icon: <BookOpen className="w-5 h-5" /> },
1919
{ title: 'Resources', href: '/admin/dashboard/resources', icon: <BookOpen className="w-5 h-5" /> },
20+
{ title: 'Projects', href: '/admin/dashboard/projects', icon: <Rocket className="w-5 h-5" /> },
21+
{ title: 'Testimonials', href: '/admin/dashboard/testimonials', icon: <MessageSquareQuote className="w-5 h-5" /> },
2022
];
2123

2224
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
Lines changed: 315 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,315 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
5+
import { Button } from '@/components/ui/button';
6+
import { Badge } from '@/components/ui/badge';
7+
import { AdminAddProjectModal } from '@/components/admin-add-project-modal';
8+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
9+
import { Loader2, CheckCircle, XCircle, Trash2, ExternalLink, Eye, Github, Video } from 'lucide-react';
10+
import Image from 'next/image';
11+
import Link from 'next/link';
12+
13+
interface Project {
14+
_id: string;
15+
title: string;
16+
description: string;
17+
imageUrl: string;
18+
demoUrl?: string;
19+
videoDemoUrl?: string;
20+
githubUrl?: string;
21+
submitterName: string;
22+
submitterEmail: string;
23+
isApproved: boolean;
24+
createdAt: string;
25+
}
26+
27+
export default function AdminProjectsPage() {
28+
const [projects, setProjects] = useState<Project[]>([]);
29+
const [loading, setLoading] = useState(true);
30+
const [processingId, setProcessingId] = useState<string | null>(null);
31+
const [viewProject, setViewProject] = useState<Project | null>(null);
32+
33+
const fetchProjects = async () => {
34+
try {
35+
const res = await fetch('/api/admin/projects');
36+
if (res.ok) {
37+
const data = await res.json();
38+
setProjects(data);
39+
}
40+
} catch (error) {
41+
console.error('Failed to fetch projects', error);
42+
} finally {
43+
setLoading(false);
44+
}
45+
};
46+
47+
useEffect(() => {
48+
fetchProjects();
49+
}, []);
50+
51+
const handleStatusChange = async (id: string, newStatus: boolean) => {
52+
setProcessingId(id);
53+
try {
54+
const res = await fetch(`/api/admin/projects/${id}`, {
55+
method: 'PATCH',
56+
headers: { 'Content-Type': 'application/json' },
57+
body: JSON.stringify({ isApproved: newStatus }),
58+
});
59+
60+
if (res.ok) {
61+
setProjects(projects.map(p =>
62+
p._id === id ? { ...p, isApproved: newStatus } : p
63+
));
64+
}
65+
} catch (error) {
66+
console.error('Failed to update status', error);
67+
} finally {
68+
setProcessingId(null);
69+
}
70+
};
71+
72+
const handleDelete = async (id: string) => {
73+
if (!confirm('Are you sure you want to delete this project?')) return;
74+
75+
setProcessingId(id);
76+
try {
77+
const res = await fetch(`/api/admin/projects/${id}`, {
78+
method: 'DELETE',
79+
});
80+
81+
if (res.ok) {
82+
setProjects(projects.filter(p => p._id !== id));
83+
}
84+
} catch (error) {
85+
console.error('Failed to delete project', error);
86+
} finally {
87+
setProcessingId(null);
88+
}
89+
};
90+
91+
if (loading) {
92+
return (
93+
<div className="flex items-center justify-center h-96">
94+
<Loader2 className="w-8 h-8 animate-spin" />
95+
</div>
96+
);
97+
}
98+
99+
return (
100+
<div className="space-y-6">
101+
<div className="flex justify-between items-center">
102+
<h2 className="text-3xl font-bold tracking-tight">Community Projects</h2>
103+
<AdminAddProjectModal onSuccess={fetchProjects} />
104+
</div>
105+
106+
<Card>
107+
<CardHeader>
108+
<CardTitle>Project List</CardTitle>
109+
</CardHeader>
110+
<CardContent>
111+
<div className="relative overflow-x-auto">
112+
<table className="w-full text-sm text-left">
113+
<thead className="text-xs uppercase bg-muted/50">
114+
<tr>
115+
<th className="px-6 py-3">Project</th>
116+
<th className="px-6 py-3">Submitter</th>
117+
<th className="px-6 py-3">Links</th>
118+
<th className="px-6 py-3">Status</th>
119+
<th className="px-6 py-3">Date</th>
120+
<th className="px-6 py-3 text-right">Actions</th>
121+
</tr>
122+
</thead>
123+
<tbody>
124+
{projects.map((project) => (
125+
<tr key={project._id} className="border-b hover:bg-muted/50">
126+
<td className="px-6 py-4">
127+
<div className="flex items-center gap-3">
128+
<div className="relative w-12 h-12 rounded overflow-hidden bg-muted shrink-0">
129+
<Image
130+
src={project.imageUrl}
131+
alt={project.title}
132+
fill
133+
className="object-cover"
134+
/>
135+
</div>
136+
<div className="max-w-[200px]">
137+
<div className="font-medium truncate" title={project.title}>{project.title}</div>
138+
<div className="text-xs text-muted-foreground truncate" title={project.description}>
139+
{project.description}
140+
</div>
141+
</div>
142+
</div>
143+
</td>
144+
<td className="px-6 py-4">
145+
<div className="font-medium">{project.submitterName}</div>
146+
<div className="text-xs text-muted-foreground">{project.submitterEmail}</div>
147+
</td>
148+
<td className="px-6 py-4">
149+
<div className="flex gap-2">
150+
{project.demoUrl && (
151+
<Link href={project.demoUrl} target="_blank" className="text-blue-500 hover:underline">
152+
Demo
153+
</Link>
154+
)}
155+
{project.githubUrl && (
156+
<Link href={project.githubUrl} target="_blank" className="text-blue-500 hover:underline">
157+
Code
158+
</Link>
159+
)}
160+
</div>
161+
</td>
162+
<td className="px-6 py-4">
163+
<Badge variant={project.isApproved ? "default" : "secondary"} className={project.isApproved ? "bg-green-500 hover:bg-green-600" : ""}>
164+
{project.isApproved ? "Live" : "Pending"}
165+
</Badge>
166+
</td>
167+
<td className="px-6 py-4 text-muted-foreground">
168+
{project.createdAt ? new Date(project.createdAt).toLocaleDateString('en-US', {
169+
year: 'numeric',
170+
month: 'short',
171+
day: 'numeric'
172+
}) : 'N/A'}
173+
</td>
174+
<td className="px-6 py-4 text-right">
175+
<div className="flex justify-end gap-2">
176+
<Button
177+
size="icon"
178+
variant="ghost"
179+
className="hover:bg-muted"
180+
onClick={() => setViewProject(project)}
181+
title="View Details"
182+
>
183+
<Eye className="w-4 h-4" />
184+
</Button>
185+
{project.isApproved ? (
186+
<Button
187+
size="icon"
188+
variant="ghost"
189+
className="text-orange-500 hover:text-orange-600 hover:bg-orange-50"
190+
onClick={() => handleStatusChange(project._id, false)}
191+
disabled={processingId === project._id}
192+
title="Unpublish"
193+
>
194+
<XCircle className="w-4 h-4" />
195+
</Button>
196+
) : (
197+
<Button
198+
size="icon"
199+
variant="ghost"
200+
className="text-green-500 hover:text-green-600 hover:bg-green-50"
201+
onClick={() => handleStatusChange(project._id, true)}
202+
disabled={processingId === project._id}
203+
title="Approve"
204+
>
205+
<CheckCircle className="w-4 h-4" />
206+
</Button>
207+
)}
208+
<Button
209+
size="icon"
210+
variant="ghost"
211+
className="text-red-500 hover:text-red-600 hover:bg-red-50"
212+
onClick={() => handleDelete(project._id)}
213+
disabled={processingId === project._id}
214+
title="Delete"
215+
>
216+
{processingId === project._id ? (
217+
<Loader2 className="w-4 h-4 animate-spin" />
218+
) : (
219+
<Trash2 className="w-4 h-4" />
220+
)}
221+
</Button>
222+
</div>
223+
</td>
224+
</tr>
225+
))}
226+
{projects.length === 0 && (
227+
<tr>
228+
<td colSpan={6} className="px-6 py-8 text-center text-muted-foreground">
229+
No project submissions found.
230+
</td>
231+
</tr>
232+
)}
233+
</tbody>
234+
</table>
235+
</div>
236+
</CardContent>
237+
</Card>
238+
239+
<Dialog open={!!viewProject} onOpenChange={(open) => !open && setViewProject(null)}>
240+
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
241+
{viewProject && (
242+
<>
243+
<DialogHeader>
244+
<DialogTitle>{viewProject.title}</DialogTitle>
245+
<DialogDescription>
246+
Submitted on {new Date(viewProject.createdAt).toLocaleDateString()}
247+
</DialogDescription>
248+
</DialogHeader>
249+
250+
<div className="space-y-6">
251+
<div className="relative aspect-video w-full rounded-lg overflow-hidden bg-muted">
252+
<Image
253+
src={viewProject.imageUrl}
254+
alt={viewProject.title}
255+
fill
256+
className="object-cover"
257+
/>
258+
</div>
259+
260+
<div className="space-y-2">
261+
<h3 className="font-semibold text-lg">Description</h3>
262+
<p className="text-muted-foreground whitespace-pre-wrap">
263+
{viewProject.description}
264+
</p>
265+
</div>
266+
267+
<div className="grid grid-cols-2 gap-4">
268+
<div className="space-y-1">
269+
<h3 className="font-semibold text-sm">Submitter</h3>
270+
<p className="text-sm">{viewProject.submitterName}</p>
271+
<p className="text-sm text-muted-foreground">{viewProject.submitterEmail}</p>
272+
</div>
273+
274+
<div className="space-y-1">
275+
<h3 className="font-semibold text-sm">Status</h3>
276+
<Badge variant={viewProject.isApproved ? "default" : "secondary"}>
277+
{viewProject.isApproved ? "Live" : "Pending Review"}
278+
</Badge>
279+
</div>
280+
</div>
281+
282+
<div className="flex gap-4 pt-4">
283+
{viewProject.demoUrl && (
284+
<Button asChild className="flex-1">
285+
<Link href={viewProject.demoUrl} target="_blank">
286+
<ExternalLink className="w-4 h-4 mr-2" />
287+
View Demo
288+
</Link>
289+
</Button>
290+
)}
291+
{viewProject.videoDemoUrl && (
292+
<Button asChild variant="outline" className="flex-1">
293+
<Link href={viewProject.videoDemoUrl} target="_blank">
294+
<Video className="w-4 h-4 mr-2" />
295+
Watch Video
296+
</Link>
297+
</Button>
298+
)}
299+
{viewProject.githubUrl && (
300+
<Button asChild variant="outline" className="flex-1">
301+
<Link href={viewProject.githubUrl} target="_blank">
302+
<Github className="w-4 h-4 mr-2" />
303+
View Code
304+
</Link>
305+
</Button>
306+
)}
307+
</div>
308+
</div>
309+
</>
310+
)}
311+
</DialogContent>
312+
</Dialog>
313+
</div>
314+
);
315+
}

0 commit comments

Comments
 (0)