diff --git a/package-lock.json b/package-lock.json
index 5eee02f..b706478 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"uuid": "^13.0.0",
+ "vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
@@ -5698,6 +5699,19 @@
"uuid": "dist-node/bin/uuid"
}
},
+ "node_modules/vaul": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
+ "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-dialog": "^1.1.1"
+ },
+ "peerDependencies": {
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
+ }
+ },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/package.json b/package.json
index 6042f84..aa87439 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"uuid": "^13.0.0",
+ "vaul": "^1.1.2",
"zod": "^4.3.6",
"zustand": "^5.0.11"
},
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 0000000..b82f912
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,58 @@
+import type { ReactNode } from "react";
+
+import { cn } from "@/lib/classnames";
+
+interface ButtonProps {
+ onClick?: () => void;
+ children: ReactNode;
+ tooltip: string;
+ inverted?: boolean; // Optional prop to invert colors
+}
+
+export default function Button({
+ onClick,
+ children,
+ tooltip,
+ inverted,
+}: ButtonProps) {
+ return (
+
+
+
+ {/* Tooltip Container */}
+
+
+ );
+}
diff --git a/src/components/SideDrawer.tsx b/src/components/SideDrawer.tsx
new file mode 100644
index 0000000..82218cb
--- /dev/null
+++ b/src/components/SideDrawer.tsx
@@ -0,0 +1,91 @@
+import type { CSSProperties } from "react";
+
+import { BiX } from "react-icons/bi";
+import { Drawer } from "vaul";
+
+import Button from "@/components/Button";
+import Tag from "@/components/Tag";
+import { cn } from "@/lib/classnames";
+import useIsDesktop from "@/lib/hooks/useIsDesktop";
+import { useCourseFilters } from "@/lib/stores/useFilterStore";
+import { UserCourse } from "@/lib/types";
+
+interface CourseDetailsDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ course: UserCourse;
+}
+
+export function CourseDetailsDrawer({
+ open,
+ onOpenChange,
+ course,
+}: CourseDetailsDrawerProps) {
+ const isDesktop = useIsDesktop();
+ const { attrFilters, semFilters } = useCourseFilters(course.data);
+
+ const direction = isDesktop ? "left" : "bottom";
+
+ return (
+
+
+
+
+
+
+
+ {course.name}
+
+
+ {course.data.title}
+
+
+
+
+
+
+
+
+
+ {attrFilters.map((attr) => {
+ return ;
+ })}
+ {semFilters?.map((semester) => {
+ return (
+
+ );
+ })}
+
+
+
+ {course.data.desc_text || "No description available."}
+
+
+
+
+
+ );
+}
diff --git a/src/features/catalog/Tag.tsx b/src/components/Tag.tsx
similarity index 93%
rename from src/features/catalog/Tag.tsx
rename to src/components/Tag.tsx
index 8cdd78a..48ce073 100644
--- a/src/features/catalog/Tag.tsx
+++ b/src/components/Tag.tsx
@@ -1,6 +1,5 @@
import { IoClose } from "react-icons/io5";
-import { useCatalog } from "@/features/catalog/search/context/useCatalog";
import useIsDesktop from "@/lib/hooks/useIsDesktop.ts";
import { FilterData } from "@/lib/types";
@@ -51,6 +50,7 @@ interface TagProps {
isSelected?: boolean;
showCode?: boolean;
isRemovable?: boolean;
+ onToggle?: (filter: FilterData) => void;
}
export default function Tag({
@@ -59,8 +59,8 @@ export default function Tag({
isSelectable = false,
isSelected = false,
isRemovable = false,
+ onToggle,
}: TagProps) {
- const { toggleFilter } = useCatalog();
const isDesktop = useIsDesktop();
// Determine content: value or code
@@ -78,8 +78,8 @@ export default function Tag({
: `border-darkblue text-darkblue ${accentColor.hover_bg}`;
const handleClick = () => {
- if (isSelectable || isRemovable) {
- toggleFilter(filter);
+ if ((isSelectable || isRemovable) && onToggle) {
+ onToggle(filter);
}
};
diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx
index b0ea54c..6e714ce 100644
--- a/src/components/course/Course.tsx
+++ b/src/components/course/Course.tsx
@@ -7,6 +7,7 @@ import { MdOutlineMoreHoriz, MdDragIndicator } from "react-icons/md";
import CourseBadge from "@/components/course/CourseBadge";
import CourseLabel from "@/components/course/CourseLabel";
+import { CourseDetailsDrawer } from "@/components/SideDrawer";
import { SortableItemProps } from "@/features/dnd/props";
import CourseMenuContent from "@/features/planner/components/course/CourseMenuContent";
import CreditSelector from "@/features/planner/components/CreditSelector";
@@ -92,9 +93,11 @@ function PlannerCourseView({
course,
semesterId,
}: ViewProps) {
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const menuOptions = usePlannerCourse({
course,
semesterId: semesterId ?? null,
+ onOpenDetails: () => setIsDrawerOpen(true),
});
const isTouch = useIsTouch();
@@ -176,6 +179,11 @@ function PlannerCourseView({
+
diff --git a/src/components/header/ButtonTray.tsx b/src/components/header/ButtonTray.tsx
index 2db046a..5cccc58 100644
--- a/src/components/header/ButtonTray.tsx
+++ b/src/components/header/ButtonTray.tsx
@@ -8,8 +8,8 @@ import {
BiDotsVerticalRounded,
} from "react-icons/bi";
+import Button from "@/components/Button";
import Dialog from "@/components/Dialog";
-import HeaderButton from "@/components/header/HeaderButton";
import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace";
import {
SaveFile,
@@ -29,6 +29,16 @@ export default function ButtonTray() {
const { exportPlan, importPlan } = useInputOutput();
const fileInputRef = useRef(null);
+ const handleResetClick = () => {
+ setActiveDialog("reset");
+ setIsOpen(false);
+ };
+
+ const handleExportClick = () => {
+ exportPlan();
+ setIsOpen(false);
+ };
+
const handleImportClick = () => {
fileInputRef.current?.click();
setIsOpen(false);
@@ -78,35 +88,15 @@ export default function ButtonTray() {
const buttonList = (
<>
- {
- setActiveDialog("reset");
- setIsOpen(false);
- }}
- tooltip="Reset Workspace"
- >
+
-
- {
- exportPlan();
- setIsOpen(false);
- }}
- tooltip="Export Plan"
- >
+
+
-
- {
- handleImportClick();
- setIsOpen(false);
- }}
- tooltip="Import Plan"
- >
+
+
+
>
);
@@ -123,12 +113,9 @@ export default function ButtonTray() {
{buttonList}
-
setIsOpen(!isOpen)}
- tooltip="More Options"
- >
+
+
{isOpen && (
diff --git a/src/components/header/HeaderButton.tsx b/src/components/header/HeaderButton.tsx
deleted file mode 100644
index 599a698..0000000
--- a/src/components/header/HeaderButton.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { cn } from "@/lib/classnames";
-
-interface HeaderButtonProps {
- onClick?: () => void;
- children: React.ReactNode;
- tooltip: string;
-}
-
-export default function HeaderButton({
- onClick,
- children,
- tooltip,
-}: HeaderButtonProps) {
- return (
-
-
-
- {/* Tooltip Container */}
-
-
- );
-}
diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx
index 0e156f8..38f971b 100644
--- a/src/features/catalog/components/CatalogCourse.tsx
+++ b/src/features/catalog/components/CatalogCourse.tsx
@@ -5,35 +5,18 @@ import { IoAdd } from "react-icons/io5";
import CourseBadge from "@/components/course/CourseBadge";
import CourseLabel from "@/components/course/CourseLabel";
+import Tag from "@/components/Tag";
import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace";
-import { useCatalog } from "@/features/catalog/search/context/useCatalog";
-import Tag from "@/features/catalog/Tag";
-import { APICourse, FilterData } from "@/lib/types";
-
-const findFiltersForCourse = (
- api_list: string[],
- filterDataType: FilterData[],
-) => {
- return filterDataType.filter((attr) => api_list.includes(attr.code));
-};
+import { useCourseFilters } from "@/lib/stores/useFilterStore";
+import { APICourse } from "@/lib/types";
interface CourseProps {
course: APICourse;
}
const Course: React.FC = ({ course }) => {
- const { addCourseToToolbox } = useCourseWorkspace();
- const { filters } = useCatalog();
- const { getCourseCount } = useCourseWorkspace();
-
- const attrFilters = findFiltersForCourse(
- course.attr_list || [],
- filters.attributes,
- );
- const semFilters = findFiltersForCourse(
- course.sem_list || [],
- filters.semesters,
- );
+ const { addCourseToToolbox, getCourseCount } = useCourseWorkspace();
+ const { attrFilters, semFilters } = useCourseFilters(course);
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = (e: React.MouseEvent) => {
@@ -43,7 +26,7 @@ const Course: React.FC = ({ course }) => {
return (
}
- {!isDesktop && }
+ {!isDesktop && }
{showFilterPanel && !isDesktop && }
);
diff --git a/src/features/catalog/search/context/provider.tsx b/src/features/catalog/search/context/provider.tsx
index dc8d5d0..e708a7d 100644
--- a/src/features/catalog/search/context/provider.tsx
+++ b/src/features/catalog/search/context/provider.tsx
@@ -2,24 +2,14 @@ import { ReactNode, useState, useEffect, useRef, useMemo } from "react";
import { CatalogContext } from "@/features/catalog/search/context/context";
import api from "@/lib/axios";
-import { APICourse, FilterData, FilterCategory } from "@/lib/types";
-
-const formatApiData = (type: FilterCategory, data: Record
) => {
- return Object.entries(data).map(([code, value], index) => ({
- id: index,
- code,
- value,
- type,
- }));
-};
+import { useFilterStore } from "@/lib/stores/useFilterStore";
+import { APICourse, FilterData } from "@/lib/types";
export function CatalogProvider({ children }: { children: ReactNode }) {
+ const filters = useFilterStore((state) => state.filters);
+ const fetchFilters = useFilterStore((state) => state.fetchFilters);
+
const [showFilterPanel, setShowFilterPanel] = useState(false);
- const [filters, setFilters] = useState({
- subjects: [] as FilterData[],
- attributes: [] as FilterData[],
- semesters: [] as FilterData[],
- });
const [selectedFilters, setSelectedFilters] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [searchPrompt, setSearchPrompt] = useState("");
@@ -27,27 +17,11 @@ export function CatalogProvider({ children }: { children: ReactNode }) {
const [hasSearched, setHasSearched] = useState(false);
const debounceTimeout = useRef | null>(null);
- // const lastNoResultQuery = useRef(null);
+ // Fetch filter options on mount
useEffect(() => {
- const fetchAllFilters = async () => {
- try {
- const [sub, attr, sem] = await Promise.all([
- api.get("/course/filter/values/subjects"),
- api.get("/course/filter/values/attributes"),
- api.get("/course/filter/values/semesters"),
- ]);
- setFilters({
- subjects: formatApiData("Subject", sub.data),
- attributes: formatApiData("Attribute", attr.data),
- semesters: formatApiData("Semester", sem.data),
- });
- } catch (error) {
- console.error("Failed to fetch filters:", error);
- }
- };
- fetchAllFilters();
- }, []);
+ fetchFilters();
+ }, [fetchFilters]);
const toggleFilter = (filter: FilterData) => {
setSelectedFilters((prev) => {
@@ -100,7 +74,7 @@ export function CatalogProvider({ children }: { children: ReactNode }) {
const value = useMemo(
() => ({
- filters,
+ filters, // <-- Still providing this down to Context consumers so existing code doesn't break
selectedFilters,
toggleFilter,
clearFilters: () => setSelectedFilters([]),
diff --git a/src/features/catalog/search/filters/FilterSection.tsx b/src/features/catalog/search/filters/FilterSection.tsx
index bc1e8ba..4ff57a9 100644
--- a/src/features/catalog/search/filters/FilterSection.tsx
+++ b/src/features/catalog/search/filters/FilterSection.tsx
@@ -2,8 +2,8 @@ import { useState } from "react";
import { motion } from "framer-motion";
+import Tag from "@/components/Tag";
import { useCatalog } from "@/features/catalog/search/context/useCatalog";
-import Tag from "@/features/catalog/Tag";
import useIsDesktop from "@/lib/hooks/useIsDesktop";
import { FilterCategory, FilterData } from "@/lib/types";
@@ -18,7 +18,7 @@ export default function FilterSection({
tags,
showCode = false,
}: FilterSectionProps) {
- const { selectedFilters } = useCatalog();
+ const { toggleFilter, selectedFilters } = useCatalog();
const isDesktop = useIsDesktop();
const [isExpanded, setIsExpanded] = useState(false);
@@ -52,6 +52,7 @@ export default function FilterSection({
(sf) => sf.code === tag.code && sf.type === tag.type,
)}
showCode={showCode}
+ onToggle={toggleFilter}
/>
))}
diff --git a/src/features/catalog/search/filters/SelectedFilters.tsx b/src/features/catalog/search/filters/SelectedFilters.tsx
index 4603656..fc4c57a 100644
--- a/src/features/catalog/search/filters/SelectedFilters.tsx
+++ b/src/features/catalog/search/filters/SelectedFilters.tsx
@@ -1,8 +1,8 @@
+import Tag from "@/components/Tag";
import { useCatalog } from "@/features/catalog/search/context/useCatalog";
-import Tag from "@/features/catalog/Tag";
export default function SelectedFilters() {
- const { selectedFilters } = useCatalog();
+ const { toggleFilter, selectedFilters } = useCatalog();
if (selectedFilters.length === 0) return null;
@@ -13,6 +13,7 @@ export default function SelectedFilters() {
key={`${filter.type}-${filter.code}`}
filter={filter}
isRemovable={true}
+ onToggle={toggleFilter}
/>
))}
diff --git a/src/features/planner/usePlannerCourse.ts b/src/features/planner/usePlannerCourse.ts
index cce31ff..c3d4a9e 100644
--- a/src/features/planner/usePlannerCourse.ts
+++ b/src/features/planner/usePlannerCourse.ts
@@ -16,11 +16,13 @@ export interface MenuOption {
interface UsePlannerCourseProps {
course: UserCourse;
semesterId: string | null;
+ onOpenDetails: () => void;
}
export const usePlannerCourse = ({
course,
semesterId,
+ onOpenDetails,
}: UsePlannerCourseProps) => {
const {
addCourseToSemester,
@@ -88,9 +90,15 @@ export const usePlannerCourse = ({
/* MENU OPTIONS */
return useMemo(
() => [
+ {
+ label: "View Course Details",
+ action: onOpenDetails,
+ disabled: semesterId === null,
+ },
{
label: "Duplicate Course",
action: handleDuplicate,
+ hasSeparatorBefore: true,
},
{
label: "Move to Next Semester",
diff --git a/src/lib/stores/useFilterStore.ts b/src/lib/stores/useFilterStore.ts
new file mode 100644
index 0000000..7d92666
--- /dev/null
+++ b/src/lib/stores/useFilterStore.ts
@@ -0,0 +1,81 @@
+import { useMemo } from "react";
+
+import { create } from "zustand";
+
+import api from "@/lib/axios";
+import { FilterData, FilterCategory, APICourse } from "@/lib/types";
+
+const formatApiData = (type: FilterCategory, data: Record) => {
+ return Object.entries(data).map(([code, value], index) => ({
+ id: index,
+ code,
+ value,
+ type,
+ }));
+};
+
+interface FilterState {
+ filters: {
+ subjects: FilterData[];
+ attributes: FilterData[];
+ semesters: FilterData[];
+ };
+ isFetchingFilters: boolean;
+ hasFetchedFilters: boolean;
+ fetchFilters: () => Promise;
+}
+
+export const useFilterStore = create((set, get) => ({
+ filters: {
+ subjects: [],
+ attributes: [],
+ semesters: [],
+ },
+ isFetchingFilters: false,
+ hasFetchedFilters: false,
+
+ fetchFilters: async () => {
+ // Prevent refetching if we have already successfully fetched the data
+ if (get().hasFetchedFilters) return;
+
+ set({ isFetchingFilters: true });
+
+ try {
+ const [sub, attr, sem] = await Promise.all([
+ api.get("/course/filter/values/subjects"),
+ api.get("/course/filter/values/attributes"),
+ api.get("/course/filter/values/semesters"),
+ ]);
+
+ set({
+ filters: {
+ subjects: formatApiData("Subject", sub.data),
+ attributes: formatApiData("Attribute", attr.data),
+ semesters: formatApiData("Semester", sem.data),
+ },
+ hasFetchedFilters: true,
+ });
+ } catch (error) {
+ console.error("Failed to fetch filters:", error);
+ } finally {
+ set({ isFetchingFilters: false });
+ }
+ },
+}));
+
+export const useCourseFilters = (course: APICourse) => {
+ // ONLY use global filters
+ const filters = useFilterStore((state) => state.filters);
+
+ return useMemo(() => {
+ const attrFilters = filters.attributes.filter((attr) =>
+ (course.attr_list || []).includes(attr.code),
+ );
+
+ const semFilters = filters.semesters.filter((sem) =>
+ (course.sem_list || []).includes(sem.code),
+ );
+
+ return { attrFilters, semFilters };
+ }, [filters, course.attr_list, course.sem_list]);
+};
diff --git a/src/pages/Catalog.tsx b/src/pages/Catalog.tsx
index 0a653a8..a582096 100644
--- a/src/pages/Catalog.tsx
+++ b/src/pages/Catalog.tsx
@@ -1,10 +1,9 @@
// import { useDroppable } from "@dnd-kit/core";
import CatalogResults from "@/features/catalog/components/CatalogResults";
-// import TrashDropZone from "@/features/catalog/components/TrashDropzone";
import { CatalogProvider } from "@/features/catalog/search/context/provider";
import { useCatalog } from "@/features/catalog/search/context/useCatalog";
-import SelectedFiltersList from "@/features/catalog/search/filters/SelectedFilters";
+import SelectedFilters from "@/features/catalog/search/filters/SelectedFilters";
import SearchBar from "@/features/catalog/search/SearchBar";
import useIsDesktop from "@/lib/hooks/useIsDesktop";
@@ -32,7 +31,7 @@ function CatalogContent() {
Courses
- {isDesktop && }
+ {isDesktop && }
{(isDesktop || !showFilterPanel) && }