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" - > + + + ); @@ -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 */} -
-
-
- {tooltip} -
-
-
- ); -} 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) && }