Initial CSE website content integration
This commit is contained in:
commit
dfe66b9002
260 changed files with 83153 additions and 0 deletions
953
src/components/sections/course-catalog.tsx
Normal file
953
src/components/sections/course-catalog.tsx
Normal file
|
|
@ -0,0 +1,953 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue