953 lines
31 KiB
TypeScript
953 lines
31 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import {
|
|
ArrowDownZA,
|
|
ArrowUpAZ,
|
|
BookOpen,
|
|
Clock,
|
|
ExternalLink,
|
|
FileText,
|
|
Github,
|
|
Grid2X2,
|
|
Info,
|
|
List,
|
|
CirclePlay,
|
|
Search,
|
|
Users,
|
|
X,
|
|
Youtube,
|
|
} from "lucide-react";
|
|
|
|
import coursesData from "@/data/courses.json";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const ALL = "all";
|
|
const EXCLUDED_COURSE_IDS = new Set([
|
|
"cs-302-theory-of-computation",
|
|
"cs-602-theory-of-computation",
|
|
]);
|
|
|
|
type Course = (typeof coursesData.courses)[number];
|
|
type SortDirection = "asc" | "desc";
|
|
type ViewMode = "list" | "cards";
|
|
|
|
type FeaturedResource = {
|
|
courseId: string;
|
|
title: string;
|
|
eyebrow: string;
|
|
meta: string;
|
|
url: string;
|
|
thumbnail?: string;
|
|
type: "playlist" | "repository" | "notes";
|
|
description: string;
|
|
};
|
|
|
|
const FEATURED_RESOURCES: FeaturedResource[] = [
|
|
{
|
|
courseId: "es-335-machine-learning",
|
|
title: "Machine Learning @ IIT Gandhinagar 2024",
|
|
eyebrow: "ES 335",
|
|
meta: "33 lessons",
|
|
url: "https://www.youtube.com/watch?v=XuDrvFmvk2U&list=PLftoLyLEwECDqanAL7LkZNnZG9VdNCiJF",
|
|
thumbnail: "/images/course-resources/machine-learning.png",
|
|
type: "playlist",
|
|
description: "Recorded lectures by Nipun Batra.",
|
|
},
|
|
{
|
|
courseId: "es-661-probabilistic-machine-learning",
|
|
title: "Probabilistic Machine Learning",
|
|
eyebrow: "ES 661",
|
|
meta: "24 lessons",
|
|
url: "https://www.youtube.com/watch?v=UCYaVS9_jGU&list=PLftoLyLEwECBEJyfRBJoSBd0UaTjEcs3I",
|
|
thumbnail: "/images/course-resources/probabilistic-machine-learning.png",
|
|
type: "playlist",
|
|
description: "Recorded lectures by Nipun Batra.",
|
|
},
|
|
{
|
|
courseId: "cs-330-operating-systems",
|
|
title: "Operating Systems Fall 2018 IITGN",
|
|
eyebrow: "CS 330",
|
|
meta: "31 lessons",
|
|
url: "https://www.youtube.com/watch?v=whp34MZbG6o&list=PLftoLyLEwECB3NsNfQ1oxtt8IoBNRWcO5",
|
|
thumbnail: "/images/course-resources/operating-systems.png",
|
|
type: "playlist",
|
|
description: "Recorded lectures by Nipun Batra.",
|
|
},
|
|
{
|
|
courseId: "cs-201-theory-of-computing",
|
|
title: "balu/toc",
|
|
eyebrow: "CS 201",
|
|
meta: "GitHub repository",
|
|
url: "https://github.com/balu/toc",
|
|
thumbnail: "/images/course-resources/theory-of-computing.png",
|
|
type: "repository",
|
|
description:
|
|
"Repository of Theory of Computation course material and references by Balagopal Komarath.",
|
|
},
|
|
{
|
|
courseId: "cs-614-advanced-algorithms",
|
|
title: "Parameterized Algorithms",
|
|
eyebrow: "CS 614",
|
|
meta: "NPTEL playlist",
|
|
url: "https://www.youtube.com/watch?v=9x9pAn5DkZI&list=PLyqSpQzTE6M_0KLR2_FVBN4Rnvq_PlcrA",
|
|
thumbnail: "/images/course-resources/parameterized-algorithms.png",
|
|
type: "playlist",
|
|
description:
|
|
"NPTEL course on Parameterized Algorithms by Neeldhara Misra and Saket Saurabh, closely aligned with the Advanced Algorithms course.",
|
|
},
|
|
{
|
|
courseId:
|
|
"cs-392-i-special-topics-in-computer-science-introduction-to-competitive-programming",
|
|
title: "Introduction to Competitive Programming",
|
|
eyebrow: "CS 392-I",
|
|
meta: "NPTEL playlist",
|
|
url: "https://www.youtube.com/playlist?list=PLyqSpQzTE6M9p9pKxFGpskf4voY45T2DZ",
|
|
thumbnail: "/images/course-resources/competitive-programming.png",
|
|
type: "playlist",
|
|
description:
|
|
"NPTEL course on Competitive Programming by Neeldhara Misra and Arjun Arul, closely aligned with the Competitive Programming elective.",
|
|
},
|
|
{
|
|
courseId: "cs-328-introduction-to-data-science",
|
|
title: "Scalable Data Science",
|
|
eyebrow: "CS 328",
|
|
meta: "NPTEL playlist",
|
|
url: "https://www.youtube.com/playlist?list=PLbRMhDVUMngekIHyLt8b_3jQR7C0KUCul",
|
|
thumbnail: "/images/course-resources/scalable-data-science.png",
|
|
type: "playlist",
|
|
description:
|
|
"A NPTEL course by Anirban Dasgupta and Souransghu Bhattacharya, closely aligned with the Introduction to Data Science Course.",
|
|
},
|
|
{
|
|
courseId: "em-614-advanced-probability-and-statistics",
|
|
title: "Probability and Random Processes Lecture Notes",
|
|
eyebrow: "EM 614",
|
|
meta: "Lecture notes",
|
|
url: "https://shanmuga.people.iitgn.ac.in/Data/ProbabilityRPLectureNotes_Shanmuga.pdf",
|
|
thumbnail: "/images/course-resources/probability-lecture-notes.png",
|
|
type: "notes",
|
|
description: "Lecture notes by Shanmuganathan Raman.",
|
|
},
|
|
];
|
|
|
|
function getFeaturedResourcesForCourse(courseId: string) {
|
|
return FEATURED_RESOURCES.filter((resource) => resource.courseId === courseId);
|
|
}
|
|
|
|
function ResourceIcon({
|
|
type,
|
|
className,
|
|
}: {
|
|
type: FeaturedResource["type"];
|
|
className?: string;
|
|
}) {
|
|
if (type === "repository") return <Github className={className} />;
|
|
if (type === "notes") return <FileText className={className} />;
|
|
return <CirclePlay className={className} />;
|
|
}
|
|
|
|
function ResourceSourceIcon({
|
|
type,
|
|
className,
|
|
}: {
|
|
type: FeaturedResource["type"];
|
|
className?: string;
|
|
}) {
|
|
if (type === "repository") return <Github className={className} />;
|
|
if (type === "notes") return <FileText className={className} />;
|
|
return <Youtube className={className} />;
|
|
}
|
|
|
|
function resourceActionLabel(type: FeaturedResource["type"]) {
|
|
if (type === "notes") return "Open notes";
|
|
return type === "repository" ? "Open repository" : "Watch playlist";
|
|
}
|
|
|
|
function formatCredits(course: Course) {
|
|
if (!course.credits.length) return "Credits not specified";
|
|
return `${course.creditLabel} ${
|
|
course.credits.length === 1 && course.credits[0] === 1
|
|
? "credit"
|
|
: "credits"
|
|
}`;
|
|
}
|
|
|
|
function courseSearchText(course: Course) {
|
|
return [
|
|
course.code,
|
|
course.title,
|
|
course.creditLabel,
|
|
course.discipline,
|
|
course.program,
|
|
course.courseType,
|
|
course.level,
|
|
course.semester,
|
|
course.instructors.join(" "),
|
|
course.prerequisites.join(" "),
|
|
course.content,
|
|
course.textbooks,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function truncateText(value: string | null, maxLength: number) {
|
|
if (!value) return null;
|
|
if (value.length <= maxLength) return value;
|
|
return `${value.slice(0, maxLength).trimEnd()}...`;
|
|
}
|
|
|
|
function optionLabel(value: string, count?: number) {
|
|
return count === undefined ? value : `${value} (${count})`;
|
|
}
|
|
|
|
function programTone(program: string) {
|
|
if (program === "Data Science for Decision Making") {
|
|
return {
|
|
card: "border-l-sky-500/45 bg-sky-500/[0.025]",
|
|
badge: "border-sky-500/25 bg-sky-500/10 text-sky-700 dark:text-sky-300",
|
|
};
|
|
}
|
|
|
|
if (program === "Integrated Circuit Design and Technology") {
|
|
return {
|
|
card: "border-l-emerald-500/45 bg-emerald-500/[0.025]",
|
|
badge:
|
|
"border-emerald-500/25 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
|
};
|
|
}
|
|
|
|
return {
|
|
card: "border-l-primary/45 bg-primary/[0.025]",
|
|
badge: "border-primary/20 bg-primary/10 text-primary",
|
|
};
|
|
}
|
|
|
|
function countBy(courses: Course[], key: keyof Course) {
|
|
const counts = new Map<string, number>();
|
|
|
|
for (const course of courses) {
|
|
const value = course[key];
|
|
|
|
if (typeof value === "string" && value) {
|
|
counts.set(value, (counts.get(value) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
function CourseBadges({ course }: { course: Course }) {
|
|
return (
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant="secondary">{formatCredits(course)}</Badge>
|
|
<Badge variant="outline">{course.level}</Badge>
|
|
<Badge variant="outline">{course.courseType}</Badge>
|
|
{course.semester && <Badge variant="outline">{course.semester}</Badge>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CourseSummary({
|
|
course,
|
|
view,
|
|
onOpen,
|
|
}: {
|
|
course: Course;
|
|
view: ViewMode;
|
|
onOpen: (course: Course) => void;
|
|
}) {
|
|
const contentPreview =
|
|
truncateText(course.content, view === "cards" ? 180 : 260) ??
|
|
"Detailed syllabus text is not available in the current catalogue extract.";
|
|
const instructors = course.instructors.length
|
|
? course.instructors.join(", ")
|
|
: "To be announced";
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
"hover:border-primary/40 gap-0 rounded-lg border-l-4 p-5 transition-colors",
|
|
programTone(course.program).card,
|
|
view === "cards" && "h-full",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"flex h-full gap-4",
|
|
view === "cards"
|
|
? "flex-col"
|
|
: "flex-col md:flex-row md:items-start md:justify-between",
|
|
)}
|
|
>
|
|
<div className="min-w-0 flex-1 space-y-3">
|
|
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
|
|
<span className="text-foreground inline-flex items-center gap-1 font-medium">
|
|
<BookOpen className="size-3.5" />
|
|
{course.code}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"inline-flex max-w-full rounded-full border px-2 py-0.5 font-medium",
|
|
programTone(course.program).badge,
|
|
)}
|
|
>
|
|
<span className="truncate">{course.program}</span>
|
|
</span>
|
|
{course.ltpLabel && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="size-3.5" />
|
|
L-T-P {course.ltpLabel}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<h2 className="text-lg leading-snug font-semibold">{course.title}</h2>
|
|
|
|
<CourseBadges course={course} />
|
|
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{contentPreview}
|
|
</p>
|
|
|
|
<div className="text-muted-foreground flex flex-wrap items-center gap-2 text-xs">
|
|
<Users className="size-3.5" />
|
|
<span className="min-w-0">{instructors}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn(view === "list" && "shrink-0")}>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full md:w-auto"
|
|
onClick={() => onOpen(course)}
|
|
>
|
|
<Info className="size-4" />
|
|
Details
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function DetailRow({
|
|
label,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
value: string | null | undefined;
|
|
}) {
|
|
return (
|
|
<>
|
|
<dt className="text-muted-foreground">{label}</dt>
|
|
<dd>{value || "Not specified"}</dd>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CourseDetailModal({
|
|
course,
|
|
onClose,
|
|
}: {
|
|
course: Course | undefined;
|
|
onClose: () => void;
|
|
}) {
|
|
useEffect(() => {
|
|
if (!course) return;
|
|
|
|
const previousOverflow = document.body.style.overflow;
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
if (event.key === "Escape") onClose();
|
|
}
|
|
|
|
document.body.style.overflow = "hidden";
|
|
document.addEventListener("keydown", onKeyDown);
|
|
|
|
return () => {
|
|
document.body.style.overflow = previousOverflow;
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
}, [course, onClose]);
|
|
|
|
if (!course) return null;
|
|
|
|
const resources = getFeaturedResourcesForCourse(course.id);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div
|
|
className="bg-background/80 absolute inset-0 backdrop-blur-sm"
|
|
aria-hidden="true"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="course-detail-title"
|
|
className="bg-background relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border shadow-xl"
|
|
>
|
|
<div className="border-b p-5">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<p className="text-primary text-sm font-medium">{course.code}</p>
|
|
<h2
|
|
id="course-detail-title"
|
|
className="mt-1 text-2xl leading-tight font-semibold"
|
|
>
|
|
{course.title}
|
|
</h2>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
aria-label="Close course details"
|
|
onClick={onClose}
|
|
>
|
|
<X className="size-5" />
|
|
</Button>
|
|
</div>
|
|
<div className="mt-4">
|
|
<Badge
|
|
variant="outline"
|
|
className={cn("mb-3", programTone(course.program).badge)}
|
|
>
|
|
{course.program}
|
|
</Badge>
|
|
<CourseBadges course={course} />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-y-auto p-5">
|
|
{resources.length > 0 && (
|
|
<section className="bg-primary/5 mb-6 rounded-lg border p-4">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="min-w-0">
|
|
<div className="text-primary mb-1 flex items-center gap-2 text-sm font-semibold">
|
|
<ResourceSourceIcon
|
|
type={resources[0]?.type ?? "playlist"}
|
|
className="size-4"
|
|
/>
|
|
Featured course resource
|
|
</div>
|
|
<p className="text-muted-foreground text-sm">
|
|
Public resources are available for this course.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{resources.map((resource) => (
|
|
<Button key={resource.url} asChild size="sm">
|
|
<a
|
|
href={resource.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<ResourceIcon type={resource.type} className="size-4" />
|
|
{resourceActionLabel(resource.type)}
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<dl className="grid gap-x-6 gap-y-3 text-sm md:grid-cols-[10rem_1fr_10rem_1fr]">
|
|
<DetailRow label="Program" value={course.program} />
|
|
<DetailRow label="Discipline" value={course.discipline} />
|
|
<DetailRow label="Credits" value={formatCredits(course)} />
|
|
<DetailRow
|
|
label="L-T-P"
|
|
value={
|
|
course.ltpLabel
|
|
? `${course.ltpLabel} (lecture-theory-practical)`
|
|
: null
|
|
}
|
|
/>
|
|
<DetailRow label="Semester" value={course.semester} />
|
|
<DetailRow label="Category" value={course.courseType} />
|
|
<DetailRow label="Level" value={course.level} />
|
|
<DetailRow
|
|
label="Potential instructors"
|
|
value={
|
|
course.instructors.length
|
|
? course.instructors.join(", ")
|
|
: undefined
|
|
}
|
|
/>
|
|
<DetailRow
|
|
label="Prerequisites"
|
|
value={
|
|
course.prerequisites.length
|
|
? course.prerequisites.join(", ")
|
|
: undefined
|
|
}
|
|
/>
|
|
</dl>
|
|
|
|
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
|
<section>
|
|
<div className="mb-2 flex items-center gap-2 font-semibold">
|
|
<FileText className="size-4" />
|
|
Course Content
|
|
</div>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{course.content ||
|
|
"Detailed syllabus text is not available in the current catalogue extract."}
|
|
</p>
|
|
</section>
|
|
|
|
<section>
|
|
<div className="mb-2 flex items-center gap-2 font-semibold">
|
|
<BookOpen className="size-4" />
|
|
Textbooks and References
|
|
</div>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{course.textbooks ||
|
|
"Textbook and reference details are not available in the current catalogue extract."}
|
|
</p>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FeaturedResources({
|
|
courses,
|
|
onOpenCourse,
|
|
}: {
|
|
courses: Course[];
|
|
onOpenCourse: (course: Course) => void;
|
|
}) {
|
|
const coursesById = new Map(courses.map((course) => [course.id, course]));
|
|
|
|
return (
|
|
<section className="border-t bg-background py-8">
|
|
<div className="container">
|
|
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
|
<div>
|
|
<div className="text-primary mb-2 flex items-center gap-2 text-sm font-semibold">
|
|
<BookOpen className="size-4" />
|
|
Featured resources
|
|
</div>
|
|
<h2 className="text-2xl font-semibold">Featured course resources</h2>
|
|
</div>
|
|
<p className="text-muted-foreground max-w-2xl text-sm">
|
|
Selected public resources are linked to their corresponding course
|
|
catalogue entries.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
{FEATURED_RESOURCES.map((resource) => {
|
|
const course = coursesById.get(resource.courseId);
|
|
|
|
if (!course) return null;
|
|
|
|
return (
|
|
<Card
|
|
key={resource.url}
|
|
className="group gap-0 overflow-hidden rounded-lg py-0 transition-colors hover:border-primary/40"
|
|
>
|
|
<a
|
|
href={resource.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="relative block aspect-video overflow-hidden bg-muted"
|
|
aria-label={`Open ${resource.title}`}
|
|
>
|
|
{resource.thumbnail ? (
|
|
<img
|
|
src={resource.thumbnail}
|
|
alt=""
|
|
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
loading="lazy"
|
|
/>
|
|
) : (
|
|
<div className="from-primary/20 to-muted flex h-full w-full items-center justify-center bg-gradient-to-br">
|
|
<ResourceSourceIcon
|
|
type={resource.type}
|
|
className="text-primary size-14"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/65 via-black/5 to-transparent" />
|
|
<div className="absolute right-3 bottom-3 inline-flex items-center gap-1 rounded-md bg-black/75 px-2 py-1 text-xs font-medium text-white">
|
|
<ResourceIcon type={resource.type} className="size-3.5" />
|
|
{resource.meta}
|
|
</div>
|
|
</a>
|
|
|
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="secondary">{resource.eyebrow}</Badge>
|
|
<span className="text-muted-foreground text-xs">
|
|
{course.title}
|
|
</span>
|
|
</div>
|
|
<h3 className="text-base leading-snug font-semibold">
|
|
{resource.title}
|
|
</h3>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
{resource.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="mt-auto flex flex-wrap gap-2">
|
|
<Button asChild size="sm">
|
|
<a
|
|
href={resource.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<ResourceIcon type={resource.type} className="size-4" />
|
|
{resource.type === "playlist" ? "Watch" : "Open"}
|
|
</a>
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onOpenCourse(course)}
|
|
>
|
|
<Info className="size-4" />
|
|
Course details
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export default function CourseCatalog() {
|
|
const courses = useMemo(
|
|
() =>
|
|
coursesData.courses.filter(
|
|
(course) => !EXCLUDED_COURSE_IDS.has(course.id),
|
|
),
|
|
[],
|
|
);
|
|
const [query, setQuery] = useState("");
|
|
const [selectedProgram, setSelectedProgram] = useState(ALL);
|
|
const [selectedLevel, setSelectedLevel] = useState(ALL);
|
|
const [selectedSemester, setSelectedSemester] = useState(ALL);
|
|
const [selectedCredit, setSelectedCredit] = useState(ALL);
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
|
|
const [view, setView] = useState<ViewMode>("list");
|
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null);
|
|
|
|
const searchIndex = useMemo(
|
|
() =>
|
|
new Map(courses.map((course) => [course.id, courseSearchText(course)])),
|
|
[courses],
|
|
);
|
|
const programCounts = useMemo(() => countBy(courses, "program"), [courses]);
|
|
const levelCounts = useMemo(() => countBy(courses, "level"), [courses]);
|
|
const semesterCounts = useMemo(() => countBy(courses, "semester"), [courses]);
|
|
const selectedCourse = useMemo(
|
|
() => courses.find((course) => course.id === selectedCourseId),
|
|
[courses, selectedCourseId],
|
|
);
|
|
|
|
const filteredCourses = useMemo(() => {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
|
|
return courses.filter((course) => {
|
|
if (
|
|
normalizedQuery &&
|
|
!searchIndex.get(course.id)?.includes(normalizedQuery)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (selectedProgram !== ALL && course.program !== selectedProgram) {
|
|
return false;
|
|
}
|
|
|
|
if (selectedLevel !== ALL && course.level !== selectedLevel) {
|
|
return false;
|
|
}
|
|
|
|
if (selectedSemester !== ALL && course.semester !== selectedSemester) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
selectedCredit !== ALL &&
|
|
!course.credits.some((credit) => String(credit) === selectedCredit)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [
|
|
courses,
|
|
query,
|
|
searchIndex,
|
|
selectedCredit,
|
|
selectedLevel,
|
|
selectedProgram,
|
|
selectedSemester,
|
|
]);
|
|
|
|
const sortedCourses = useMemo(() => {
|
|
return [...filteredCourses].sort((a, b) => {
|
|
const titleOrder = a.title.localeCompare(b.title, "en", {
|
|
sensitivity: "base",
|
|
});
|
|
const codeOrder = a.code.localeCompare(b.code, "en", {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
const order = titleOrder || codeOrder;
|
|
|
|
return sortDirection === "asc" ? order : -order;
|
|
});
|
|
}, [filteredCourses, sortDirection]);
|
|
|
|
const filtersActive =
|
|
query ||
|
|
selectedProgram !== ALL ||
|
|
selectedLevel !== ALL ||
|
|
selectedSemester !== ALL ||
|
|
selectedCredit !== ALL;
|
|
|
|
function clearFilters() {
|
|
setQuery("");
|
|
setSelectedProgram(ALL);
|
|
setSelectedLevel(ALL);
|
|
setSelectedSemester(ALL);
|
|
setSelectedCredit(ALL);
|
|
}
|
|
|
|
const SortIcon = sortDirection === "asc" ? ArrowUpAZ : ArrowDownZA;
|
|
|
|
return (
|
|
<div>
|
|
<section className="from-primary/5 bg-gradient-to-b to-transparent py-14 md:py-18">
|
|
<div className="container">
|
|
<div className="max-w-3xl">
|
|
<div className="text-muted-foreground mb-4 inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs">
|
|
<BookOpen className="size-3.5" />
|
|
{coursesData.metadata.sourceLabel}
|
|
</div>
|
|
<h1 className="text-3xl md:text-4xl">Courses</h1>
|
|
<p className="text-muted-foreground mt-4 max-w-2xl">
|
|
Browse the CSE course catalogue by course code, title, level,
|
|
program, credit count, and semester format.
|
|
</p>
|
|
</div>
|
|
|
|
</div>
|
|
</section>
|
|
|
|
<FeaturedResources
|
|
courses={courses}
|
|
onOpenCourse={(course) => setSelectedCourseId(course.id)}
|
|
/>
|
|
|
|
<section className="bg-muted/20 border-y">
|
|
<div className="container py-5">
|
|
<div className="grid gap-3 lg:grid-cols-[minmax(16rem,1fr)_13rem_10rem_11rem_8rem_auto]">
|
|
<div className="relative">
|
|
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search code, title, instructor, content"
|
|
className="pl-9"
|
|
/>
|
|
</div>
|
|
|
|
<Select value={selectedProgram} onValueChange={setSelectedProgram}>
|
|
<SelectTrigger className="w-full" aria-label="Filter by program">
|
|
<SelectValue placeholder="Program" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All Programs</SelectItem>
|
|
{coursesData.metadata.filters.programs.map((program) => (
|
|
<SelectItem key={program} value={program}>
|
|
{optionLabel(program, programCounts.get(program))}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={selectedLevel} onValueChange={setSelectedLevel}>
|
|
<SelectTrigger className="w-full" aria-label="Filter by level">
|
|
<SelectValue placeholder="Level" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All Levels</SelectItem>
|
|
{coursesData.metadata.filters.levels.map((level) => (
|
|
<SelectItem key={level} value={level}>
|
|
{optionLabel(level, levelCounts.get(level))}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select
|
|
value={selectedSemester}
|
|
onValueChange={setSelectedSemester}
|
|
>
|
|
<SelectTrigger className="w-full" aria-label="Filter by semester">
|
|
<SelectValue placeholder="Semester" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All Semesters</SelectItem>
|
|
{coursesData.metadata.filters.semesters.map((semester) => (
|
|
<SelectItem key={semester} value={semester}>
|
|
{optionLabel(semester, semesterCounts.get(semester))}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Select value={selectedCredit} onValueChange={setSelectedCredit}>
|
|
<SelectTrigger className="w-full" aria-label="Filter by credits">
|
|
<SelectValue placeholder="Credits" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value={ALL}>All Credits</SelectItem>
|
|
{coursesData.metadata.filters.credits.map((credit) => (
|
|
<SelectItem key={credit} value={String(credit)}>
|
|
{credit} credits
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full lg:w-auto"
|
|
onClick={clearFilters}
|
|
disabled={!filtersActive}
|
|
>
|
|
<X className="size-4" />
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-muted-foreground text-sm">
|
|
Showing{" "}
|
|
<span className="text-foreground font-medium">
|
|
{filteredCourses.length}
|
|
</span>{" "}
|
|
of {courses.length} catalogue entries
|
|
</p>
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
aria-label={`Sort by name ${
|
|
sortDirection === "asc" ? "descending" : "ascending"
|
|
}`}
|
|
onClick={() =>
|
|
setSortDirection((current) =>
|
|
current === "asc" ? "desc" : "asc",
|
|
)
|
|
}
|
|
>
|
|
<SortIcon className="size-4" />
|
|
{sortDirection === "asc" ? "Name A-Z" : "Name Z-A"}
|
|
</Button>
|
|
|
|
<div className="bg-background inline-flex rounded-md border p-1">
|
|
<Button
|
|
type="button"
|
|
variant={view === "list" ? "default" : "ghost"}
|
|
size="sm"
|
|
aria-pressed={view === "list"}
|
|
onClick={() => setView("list")}
|
|
>
|
|
<List className="size-4" />
|
|
List
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant={view === "cards" ? "default" : "ghost"}
|
|
size="sm"
|
|
aria-pressed={view === "cards"}
|
|
onClick={() => setView("cards")}
|
|
>
|
|
<Grid2X2 className="size-4" />
|
|
Cards
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="section-padding">
|
|
<div className="container">
|
|
<div
|
|
className={cn(
|
|
view === "cards"
|
|
? "grid gap-4 md:grid-cols-2 xl:grid-cols-3"
|
|
: "space-y-3",
|
|
)}
|
|
>
|
|
{sortedCourses.map((course) => (
|
|
<CourseSummary
|
|
key={course.id}
|
|
course={course}
|
|
view={view}
|
|
onOpen={(nextCourse) => setSelectedCourseId(nextCourse.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{sortedCourses.length === 0 && (
|
|
<div className="rounded-lg border border-dashed py-16 text-center">
|
|
<h2 className="font-semibold">No courses found</h2>
|
|
<p className="text-muted-foreground mt-2 text-sm">
|
|
Adjust the filters or search terms.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
<CourseDetailModal
|
|
course={selectedCourse}
|
|
onClose={() => setSelectedCourseId(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|