Skip to content
Open
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
9 changes: 9 additions & 0 deletions admin/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Login from "./pages/Login";
import { ToastContainer } from "react-toastify";
import 'react-toastify/dist/ReactToastify.css';
import CourseDashboard from "./pages/CourseDashboard";
import PendingApprovals from "./pages/PendingContributions";

function App() {
return (
Expand Down Expand Up @@ -74,6 +75,14 @@ function App() {
</div>
}
/>
<Route
path = "/admin/pending-approvals"
element = {
<PrivateRoute>
<PendingApprovals/>
</PrivateRoute>
}
/>
</Routes>
</main>
</div>
Expand Down
38 changes: 38 additions & 0 deletions admin/src/apis/courses.js
Original file line number Diff line number Diff line change
Expand Up @@ -246,3 +246,41 @@ export const deleteNode = async(type,id) =>
}
}


export const getFileDownloadUrl = async (fileId) => {
try {
const response = await fetch(`${API_BASE_URL}api/file/download/${fileId}`, {
headers: {
Authorization: "Bearer admin-coursehub-cc23-golang",
},
credentials: "include",
});
if (!response.ok) {
throw new Error("Failed to get download link");
}
return await response.json(); // { url: "..." }
} catch (error) {
console.error("Error getting download link:", error);
throw error;
}
};


export const fetchAllPendingContributions = async()=>
{
try
{
const response = await fetch(`${API_BASE_URL}api/admin/contribution/pending`, {
headers: {Authorization: "Bearer admin-coursehub-cc23-golang"},
credentials: "include",
});

if (!response.ok) {
throw new Error("Failed to get pending contributions");
}
return await response.json();
} catch (error) {
console.error("Error fetching pending contributions:", error);
throw error;
}
}
4 changes: 3 additions & 1 deletion admin/src/components/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import React from "react";
import { Link, useLocation } from "react-router-dom";
import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle } from "react-icons/fa";
import { FaBook, FaUsers, FaLayerGroup, FaLink, FaUserGraduate, FaExclamationTriangle, FaClock } from "react-icons/fa";
import { adminLogout } from "@/apis/auth";
import { FcAlarmClock } from "react-icons/fc";

const navItems = [
{ label: "Students", to: "/admin/students", icon: FaUserGraduate },
{ label: "Courses", to: "/admin/courses", icon: FaBook },
{ label: "Course Linking", to: "/admin/course-linking", icon: FaLink },
{ label: "Courses Without BR", to: "/admin/courses-without-br", icon: FaExclamationTriangle },
{ label: "Pending Approvals", to: "/admin/pending-approvals", icon: FaClock },
];

const Sidebar = () => {
Expand Down
63 changes: 55 additions & 8 deletions admin/src/pages/CourseDashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useParams} from "react-router-dom";
import React, { useEffect, useState } from "react";
import { fetchCourseDashboardData, deleteNode, handleContribution } from "@/apis/courses";
import { fetchCourseDashboardData, deleteNode, handleContribution, getFileDownloadUrl } from "@/apis/courses";
import {
FiFolder,
FiFile,
Expand Down Expand Up @@ -70,6 +70,7 @@ function LoadStructure({ node, onDelete, depth = 0 }) {
);
}


export default function CourseDashboard() {
const { code } = useParams();

Expand Down Expand Up @@ -154,6 +155,23 @@ export default function CourseDashboard() {

const pendingContributions = data?.contributions?.filter(c => !c.approved) || [];

const handleDownload = async(fileId) =>
{
try
{
const data = await getFileDownloadUrl(fileId);
if(data?.url)
{
window.location.href = data.url;
}
}
catch(error)
{
console.log(error);
alert("Failed to get download ");
}
};

return (
<div className="min-h-full bg-slate-100 text-slate-900">
<header className="flex items-center gap-4 border-b border-slate-200 bg-white px-7 py-4">
Expand Down Expand Up @@ -232,21 +250,50 @@ export default function CourseDashboard() {
<FiFile />
</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">
{contribution.files?.map(f => f.name).join(", ") || contribution.contributionId}
<div className = "text-sm font-semibold">
{contribution.files && contribution.files.length > 0 ? (
<div className = "flex flex-col gap 1">
{contribution.files.map((file) => (
<div key={file._id} className="flex items-center gap-2">
<span className="truncate">{file.name}</span>
{file.webUrl && (
<a
href={file.webUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 hover:underline"
>
View
</a>
)}
{file.downloadUrl && (
<button
onClick={() => handleDownload(file.fileId)}
className="text-xs text-blue-600 hover:underline"
>
Download
</button>
)}
</div>
))}
</div>
) : (
contribution.contributionId
)}
</div>
<div className="text-xs text-slate-600">Awaiting review</div>
</div>
<div className="flex flex-shrink-0 gap-2">
<button
onClick={() => handleContributionAction(contribution.contributionId, 'approve')}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3.5 py-2 text-sm font-semibold text-white hover:bg-green-700"
onClick={() => onAction(contribution.contributionId, "approve")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash when a course has pending contributions. onAction and processingId aren't defined in CourseDashboard (they look copied from PendingContributions.jsx). As soon as an unapproved contribution renders, this throws ReferenceError: processingId is not defined and the dashboard can't be used to approve/reject. The existing handleContributionAction is still here but no longer called. Suggest wiring these buttons back to handleContributionAction and adding a local processing-id state (same for the Reject button below).

disabled={processingId === contribution.contributionId}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3.5 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-60"
>
<FiCheck /> Approve
</button>
<button
onClick={() => handleContributionAction(contribution.contributionId, 'reject')}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-red-600 hover:border-red-200 hover:bg-red-50"
onClick={() => onAction(contribution.contributionId, "reject")}
disabled={processingId === contribution.contributionId}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-red-600 hover:border-red-200 hover:bg-red-50 disabled:opacity-60"
>
<FiX /> Reject
</button>
Expand Down
161 changes: 161 additions & 0 deletions admin/src/pages/PendingContributions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React, { useState, useEffect } from "react";
import {
fetchAllPendingContributions,
handleContribution,
getFileDownloadUrl,
} from "@/apis/courses";
import { toast } from "react-toastify";
import { FiFile, FiCheck, FiX, FiEye, FiDownload, FiInbox } from "react-icons/fi";

export default function PendingApprovals() {
const [pending, setPending] = useState([]);
const [loading, setLoading] = useState(true);
const [processingId, setProcessingId] = useState(null);

const loadPending = async () => {
try {
setLoading(true);
const response = await fetchAllPendingContributions();
setPending(response.pending || []);
} catch (err) {
toast.error(err.message || "Failed to load pending approvals.");
} finally {
setLoading(false);
}
};

useEffect(() => {
loadPending();
}, []);

const onAction = async (contributionId, action) => {
if (processingId) return;
try {
setProcessingId(contributionId);
await handleContribution(contributionId, action);
toast.success(action === "approve" ? "Contribution approved." : "Contribution rejected.");
// Drop the handled row without a full refetch.
setPending((prev) => prev.filter((c) => c.contributionId !== contributionId));
} catch (err) {
toast.error(err.message || `Failed to ${action} contribution.`);
} finally {
setProcessingId(null);
}
};

const onDownload = async (fileId) => {
try {
const data = await getFileDownloadUrl(fileId);
if (data?.url) {
window.location.href = data.url;
}
} catch (err) {
toast.error("Failed to get download link.");
}
};

return (
<div className="p-6 space-y-6">
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200/60 p-6 flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Pending Approvals</h1>
<p className="text-gray-600 mt-1">
All unverified files across every course, awaiting review.
</p>
</div>
{!loading && (
<span className="rounded-full bg-amber-50 px-3 py-1 text-sm font-semibold text-amber-700">
{pending.length} pending
</span>
)}
</div>

<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg border border-gray-200/60 p-6">
{loading ? (
<p className="text-sm text-slate-500">Loading pending approvals...</p>
) : pending.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<FiInbox className="mb-3 text-3xl text-slate-300" />
<div className="text-sm font-semibold text-slate-900">You're all caught up</div>
<div className="mt-0.5 text-sm text-slate-500">
No files are waiting for approval.
</div>
</div>
) : (
<div className="space-y-3">
{pending.map((contribution) => (
<div
key={contribution.contributionId}
className="flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 px-3.5 py-3"
>
<span className="grid h-9 w-9 flex-shrink-0 place-items-center rounded-lg bg-amber-50 text-base text-amber-700">
<FiFile />
</span>

<div className="min-w-0 flex-1">
<div className="text-xs font-semibold uppercase text-blue-700">
{contribution.courseCode}
</div>
{contribution.files && contribution.files.length > 0 ? (
<div className="mt-1 flex flex-col gap-1">
{contribution.files.map((file) => (
<div
key={file._id}
className="flex flex-wrap items-center gap-2"
>
<span className="truncate text-sm font-medium text-slate-900">
{file.name}
</span>
{file.webUrl && (
<a
href={file.webUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-600 hover:border-slate-300 hover:bg-slate-50"
>
<FiEye /> View
</a>
)}
{file.fileId && (
<button
onClick={() => onDownload(file.fileId)}
className="inline-flex items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-600 hover:border-slate-300 hover:bg-slate-50"
>
<FiDownload /> Download
</button>
)}
</div>
))}
</div>
) : (
<div className="mt-1 text-sm text-slate-500">
{contribution.contributionId} (no files)
</div>
)}
<div className="mt-1 text-xs text-slate-500">Awaiting review</div>
</div>

<div className="flex flex-shrink-0 gap-2">
<button
onClick={() => onAction(contribution.contributionId, "approve")}
disabled={processingId === contribution.contributionId}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3.5 py-2 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-60"
>
<FiCheck /> Approve
</button>
<button
onClick={() => onAction(contribution.contributionId, "reject")}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reject deletes files without confirmation. On the server, rejecting permanently deletes every file in the contribution from storage, removes them from the parent folder and deletes the contribution record. One misclick here loses a student's upload for good. The course dashboard confirms before rejecting; this page should too.

disabled={processingId === contribution.contributionId}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-red-600 hover:border-red-200 hover:bg-red-50 disabled:opacity-60"
>
<FiX /> Reject
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
3 changes: 2 additions & 1 deletion server/modules/admin/admin.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import multer from "multer";
import { adminLogin, adminLogout } from "./auth.controller.js";
import { uploadCourses, renameCourse, deleteCourse, linkLegacyCourse, bulkLinkCourses, syncCoursesCacheController } from "./adminDashboard.controller.js";
import {getCourseDashboardData, handleContribution, deleteNode} from "./adminDashboard.controller.js"

import { getAllPendingContributions } from "./adminDashboard.controller.js";
const router = express.Router();
const upload = multer({ dest: "uploads/" });

Expand Down Expand Up @@ -44,6 +44,7 @@ router.post(
isAdmin,
catchAsync(adminController.createNewCourseFolders)
);
router.get("/contribution/pending", isAdmin, catchAsync(getAllPendingContributions));

router.post("/courses/upload", isAdmin, upload.single("file"), uploadCourses);
router.post("/courses/bulk-link", isAdmin, upload.single("file"), bulkLinkCourses);
Expand Down
14 changes: 14 additions & 0 deletions server/modules/admin/adminDashboard.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -870,3 +870,17 @@ export async function syncCoursesCacheController(req, res, next) {
next(new AppError(500, `Cache sync failed: ${err.message}`));
}
}


export async function getAllPendingContributions(req, res, next)
{
try
{
const contributions = await Contribution.find({ approved: false }).sort({ createdAt: -1 }).populate("files");
res.json({pending:contributions});
}
catch(error)
{
next(new AppError(500, `Failed to fetch pending contributions: ${error.message}`));
}
}
Loading