From de519bd7e5f74b998048dc3073dcae643ccb989b Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:27:06 -0400 Subject: [PATCH 01/17] Catalog to Planner Drag and Drop Yeah, it can do it now but it looks a little awkward. Will fix in next commit. --- src/components/course/Course.tsx | 8 +++- .../catalog/components/CatalogCourse.tsx | 12 +++++- src/features/dnd/dnd.tsx | 42 ++++++++++++++++++- .../components/semester/SemesterBlock.tsx | 5 ++- src/features/toolbox/GarbageBin.tsx | 2 + 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index f55c5f58..271cd6bc 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -111,7 +111,7 @@ function PlannerCourseView({ )} >
- @@ -122,7 +122,11 @@ function PlannerCourseView({ onOpenChange={setPopoverOpen} > - diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index cf22179a..4e4d4ca2 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import { useDraggable } from "@dnd-kit/react"; import { motion } from "framer-motion"; import { IoAdd } from "react-icons/io5"; @@ -26,6 +27,12 @@ const Course: React.FC = ({ course }) => { const { filters } = useCatalog(); const { getCourseCount } = useCourseWorkspace(); + const { ref, isDragging } = useDraggable({ + id: `catalog-${course.subj_code}-${course.code_num}`, + type: "catalog-course", + data: { type: "catalog-course", course }, + }); + const attrFilters = findFiltersForCourse( course.attr_list || [], filters.attributes, @@ -43,7 +50,8 @@ const Course: React.FC = ({ course }) => { return (
= ({ addCourse }) => { return (
-
+
e.stopPropagation()}> { e.stopPropagation(); diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index 7ec3758f..612879a4 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -70,13 +70,13 @@ export default function WorkspaceDndProvider({ const handleDragStart = useCallback( (event) => { - snapshotPlanner.current = structuredClone(plannerCourses); - snapshotToolbox.current = structuredClone(toolboxCourses); - const { source } = event.operation; if (source?.data?.type === "catalog-course") { setDraggedCatalogCourse(source.data.course as APICourse); + return; } + snapshotPlanner.current = structuredClone(plannerCourses); + snapshotToolbox.current = structuredClone(toolboxCourses); }, [plannerCourses, toolboxCourses], ); From e3653ea7799ef172e85177b5e5d3ceab71d07a43 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:23:51 -0400 Subject: [PATCH 04/17] Prettier changes --- src/features/catalog/components/CatalogCourse.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index 5d3c0f84..c92791fd 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -71,7 +71,11 @@ const Course: React.FC = ({ course }) => { })}
-
e.stopPropagation()}> +
e.stopPropagation()} + > { e.stopPropagation(); From 7b2698d0dfe0f7df09a69729ee49d562f0581d47 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Tue, 31 Mar 2026 17:30:15 -0400 Subject: [PATCH 05/17] Fixing Syntax Error and Prettier change --- src/components/course/Course.tsx | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index 11a22806..8201de2f 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -125,29 +125,6 @@ function PlannerCourseView({
- - - - - - setPopoverOpen(false)} - ItemComponent="button" - SeparatorComponent="div"
- From 5ca443fe421e4823e72be943cce24c9791e2e9ec Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:27:26 -0400 Subject: [PATCH 06/17] feat(catalog): unique draggable IDs and drag handle indicator Each CatalogCourse now generates a stable UUID on mount via useRef, preventing ID collisions when multiple instances of the same course are visible. Adds a MdDragIndicator icon as a visual affordance. --- .../catalog/components/CatalogCourse.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index c92791fd..04a57ec7 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -1,8 +1,10 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useDraggable } from "@dnd-kit/react"; import { motion } from "framer-motion"; import { IoAdd } from "react-icons/io5"; +import { MdDragIndicator } from "react-icons/md"; +import { v4 as uuidv4 } from "uuid"; import CourseBadge from "@/components/course/CourseBadge"; import CourseLabel from "@/components/course/CourseLabel"; @@ -27,8 +29,10 @@ const Course: React.FC = ({ course }) => { const { filters } = useCatalog(); const { getCourseCount } = useCourseWorkspace(); + const idRef = useRef(uuidv4()); + const { ref, isDragging } = useDraggable({ - id: `catalog-${course.subj_code}-${course.code_num}`, + id: idRef.current, type: "catalog-course", data: { type: "catalog-course", course }, }); @@ -60,15 +64,21 @@ const Course: React.FC = ({ course }) => { />
-
- -
- {attrFilters.map((attr, index) => { - return ; - })} - {semFilters?.map((semester, index) => { - return ; - })} +
+ +
+ +
+ {attrFilters.map((attr, index) => { + return ; + })} + {semFilters?.map((semester, index) => { + return ; + })} +
Date: Fri, 3 Apr 2026 17:33:39 -0400 Subject: [PATCH 07/17] fix(dnd): move catalog drop logic to handleDragOver and fix missing credits - Take planner/toolbox snapshots at drag start for all drag types, including catalog drags, so cancellation correctly restores state - Remove early return for catalog-course in handleDragOver; catalog courses now live-insert into the hovered semester during drag, matching the toolbox-to-planner pattern and restoring the ghost preview and course reordering feedback - Add missing credits field (credit_max) when constructing UserCourse from an APICourse during a catalog drag --- src/features/dnd/dnd.tsx | 98 +++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 42 deletions(-) diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index 612879a4..f8ad861d 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -9,8 +9,6 @@ import { import { isSortable } from "@dnd-kit/react/sortable"; import { MdDragIndicator, MdOutlineMoreHoriz } from "react-icons/md"; -import { v4 as uuidv4 } from "uuid"; - import CourseLabel from "@/components/course/CourseLabel"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; import { APICourse, UserCourse } from "@/lib/types"; @@ -71,12 +69,11 @@ export default function WorkspaceDndProvider({ const handleDragStart = useCallback( (event) => { const { source } = event.operation; + snapshotPlanner.current = structuredClone(plannerCourses); + snapshotToolbox.current = structuredClone(toolboxCourses); if (source?.data?.type === "catalog-course") { setDraggedCatalogCourse(source.data.course as APICourse); - return; } - snapshotPlanner.current = structuredClone(plannerCourses); - snapshotToolbox.current = structuredClone(toolboxCourses); }, [plannerCourses, toolboxCourses], ); @@ -100,11 +97,62 @@ export default function WorkspaceDndProvider({ const sourceType = source.data?.type; const targetType = target.data?.type; - const sourceCourse = source.data?.course as UserCourse | undefined; - // Catalog courses are handled in onDragEnd only - if (sourceType === "catalog-course") return; + // --- CATALOG TO PLANNER --- + if (sourceType === "catalog-course") { + const apiCourse = source.data?.course as APICourse | undefined; + if (!apiCourse) return; + + const targetSemesterId = + targetType === "semester" + ? target.data?.semesterId || targetId + : courseLocationMap.get(targetId)?.semesterId; + + if (!targetSemesterId) return; + + let catalogTargetIndex: number | undefined; + if (isSortable(target)) { + catalogTargetIndex = target.index; + } else { + const targetSemester = + plannerCourses[ + courseLocationMap.get(targetId)?.semesterIndex ?? -1 + ]; + catalogTargetIndex = targetSemester?.courseList.length ?? 0; + } + + const existingLocation = courseLocationMap.get(sourceId); + const courseToPlace: UserCourse = { + id: sourceId, + name: `${apiCourse.subj_code} ${apiCourse.code_num}`, + count: 1, + credits: apiCourse.credit_max, + data: apiCourse, + }; + + if (!existingLocation) { + addCourseToSemester(targetSemesterId, courseToPlace, catalogTargetIndex); + } else if (existingLocation.semesterId === targetSemesterId) { + const currentIndex = existingLocation.index; + if ( + currentIndex !== undefined && + currentIndex !== -1 && + currentIndex !== catalogTargetIndex + ) { + moveCourseInSemester( + existingLocation.semesterId, + currentIndex, + catalogTargetIndex, + ); + } + } else { + removeCourseFromSemester(existingLocation.semesterId, sourceId); + addCourseToSemester(targetSemesterId, courseToPlace, catalogTargetIndex); + } + return; + } + const sourceCourse = source.data?.course as UserCourse | undefined; if (!sourceCourse) return; let targetIndex; @@ -197,40 +245,6 @@ export default function WorkspaceDndProvider({ const sourceType = source.data?.type; const targetType = target.data?.type; - // --- CATALOG TO PLANNER --- - if (sourceType === "catalog-course") { - const apiCourse = source.data?.course as APICourse | undefined; - if (!apiCourse) return; - - const targetSemesterId = - targetType === "semester" - ? target.data?.semesterId || targetId - : courseLocationMap.get(targetId)?.semesterId; - - if (!targetSemesterId) return; - - let targetIndex; - if (isSortable(target)) { - targetIndex = target.index; - } else { - const targetSemester = - plannerCourses[ - courseLocationMap.get(targetId)?.semesterIndex || -1 - ]; - targetIndex = targetSemester?.courseList.length || 0; - } - - const newCourse: UserCourse = { - id: uuidv4(), - name: `${apiCourse.subj_code} ${apiCourse.code_num}`, - count: 1, - data: apiCourse, - }; - - addCourseToSemester(targetSemesterId, newCourse, targetIndex); - return; - } - // --- UTILITY DROPZONES --- if (targetId === "garbage") { const semId = courseLocationMap.get(sourceId)?.semesterId; From 2b55ffb65ae9ae1f115755a509f2d17dda66d534 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:35:04 -0400 Subject: [PATCH 08/17] fix(catalog): disable drag on mobile and prevent list scroll during drag Catalog card dragging is disabled on touch devices since there is nowhere to drop. The catalog results container switches to overflow-hidden while a catalog drag is active so the AutoScroller cannot scroll the list in a direction that has no valid drop targets. --- src/features/catalog/components/CatalogCourse.tsx | 3 +++ src/features/catalog/components/CatalogResults.tsx | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index 04a57ec7..65f44675 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -12,6 +12,7 @@ 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"; +import useIsDesktop from "@/lib/hooks/useIsDesktop"; const findFiltersForCourse = ( api_list: string[], @@ -29,12 +30,14 @@ const Course: React.FC = ({ course }) => { const { filters } = useCatalog(); const { getCourseCount } = useCourseWorkspace(); + const isDesktop = useIsDesktop(); const idRef = useRef(uuidv4()); const { ref, isDragging } = useDraggable({ id: idRef.current, type: "catalog-course", data: { type: "catalog-course", course }, + disabled: !isDesktop, }); const attrFilters = findFiltersForCourse( diff --git a/src/features/catalog/components/CatalogResults.tsx b/src/features/catalog/components/CatalogResults.tsx index 08c01e71..d14449dd 100644 --- a/src/features/catalog/components/CatalogResults.tsx +++ b/src/features/catalog/components/CatalogResults.tsx @@ -1,3 +1,5 @@ +import { useDragOperation } from "@dnd-kit/react"; + import Course from "@/features/catalog/components/CatalogCourse"; import { useCatalog } from "@/features/catalog/search/context/useCatalog"; import DepartmentFilters from "@/features/catalog/search/filters/DepartmentFilters"; @@ -11,8 +13,11 @@ export default function CatalogResults() { selectedFilters, } = useCatalog(); + const operation = useDragOperation(); + const isDraggingCatalog = operation?.source?.data?.type === "catalog-course"; + return ( -
+
{searchResults.length > 0 ? (
{searchResults.map((course, index) => ( From 12bdaee20e4efea809b6c9e21925c02f6a5e8f41 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:22:42 -0400 Subject: [PATCH 09/17] fix(dnd): use per-drag UUID for catalog course to allow re-dragging The catalog card's draggable ID (stable per mount) was being used as the planner course ID. After the first drop, courseLocationMap found that ID in the planner and treated every subsequent drag from the same card as a move rather than a new insertion. Fix: generate a fresh UUID in handleDragStart for each catalog drag session and use that as the planner course ID instead of sourceId. --- src/features/dnd/dnd.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index f8ad861d..e9c6b544 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -8,6 +8,7 @@ import { } from "@dnd-kit/react"; import { isSortable } from "@dnd-kit/react/sortable"; import { MdDragIndicator, MdOutlineMoreHoriz } from "react-icons/md"; +import { v4 as uuidv4 } from "uuid"; import CourseLabel from "@/components/course/CourseLabel"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; @@ -34,6 +35,7 @@ export default function WorkspaceDndProvider({ const snapshotToolbox = useRef(structuredClone(toolboxCourses)); const snapshotPlanner = useRef(structuredClone(plannerCourses)); + const catalogDragCourseIdRef = useRef(""); const [draggedCatalogCourse, setDraggedCatalogCourse] = useState(null); @@ -73,6 +75,7 @@ export default function WorkspaceDndProvider({ snapshotToolbox.current = structuredClone(toolboxCourses); if (source?.data?.type === "catalog-course") { setDraggedCatalogCourse(source.data.course as APICourse); + catalogDragCourseIdRef.current = uuidv4(); } }, [plannerCourses, toolboxCourses], @@ -121,9 +124,10 @@ export default function WorkspaceDndProvider({ catalogTargetIndex = targetSemester?.courseList.length ?? 0; } - const existingLocation = courseLocationMap.get(sourceId); + const plannerCourseId = catalogDragCourseIdRef.current; + const existingLocation = courseLocationMap.get(plannerCourseId); const courseToPlace: UserCourse = { - id: sourceId, + id: plannerCourseId, name: `${apiCourse.subj_code} ${apiCourse.code_num}`, count: 1, credits: apiCourse.credit_max, @@ -131,7 +135,11 @@ export default function WorkspaceDndProvider({ }; if (!existingLocation) { - addCourseToSemester(targetSemesterId, courseToPlace, catalogTargetIndex); + addCourseToSemester( + targetSemesterId, + courseToPlace, + catalogTargetIndex, + ); } else if (existingLocation.semesterId === targetSemesterId) { const currentIndex = existingLocation.index; if ( @@ -146,8 +154,15 @@ export default function WorkspaceDndProvider({ ); } } else { - removeCourseFromSemester(existingLocation.semesterId, sourceId); - addCourseToSemester(targetSemesterId, courseToPlace, catalogTargetIndex); + removeCourseFromSemester( + existingLocation.semesterId, + plannerCourseId, + ); + addCourseToSemester( + targetSemesterId, + courseToPlace, + catalogTargetIndex, + ); } return; } From fe9bec09a3f0d2f9f2a5e783e918577d3b5924b3 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:27:08 -0400 Subject: [PATCH 10/17] fix(lint): fix import order in CatalogCourse and remove stale dep in dnd - Move useIsDesktop import before @/lib/types to satisfy import-x/order - Remove addCourseToSemester from handleDragEnd deps array; it is no longer called there after the catalog logic moved to handleDragOver --- src/features/catalog/components/CatalogCourse.tsx | 2 +- src/features/dnd/dnd.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index 65f44675..cf5515de 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -11,8 +11,8 @@ import CourseLabel from "@/components/course/CourseLabel"; 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"; import useIsDesktop from "@/lib/hooks/useIsDesktop"; +import { APICourse, FilterData } from "@/lib/types"; const findFiltersForCourse = ( api_list: string[], diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index e9c6b544..dc350d10 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -308,7 +308,6 @@ export default function WorkspaceDndProvider({ resetToolbox, consolidateToolbox, courseLocationMap, - addCourseToSemester, removeCourseFromSemester, removeCourseFromToolbox, insertCourseIntoToolbox, From f7bdaa45afdebb1899d5cb1bc4b8c975fc50dfc2 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:06:42 -0400 Subject: [PATCH 11/17] fix(catalog): hide drag indicator on mobile, lock inner list scroll during drag - MdDragIndicator is only rendered on desktop since mobile dragging is disabled - Apply overflow-hidden to the inner results div during catalog drags, not just the outer container, so the AutoScroller cannot scroll the list --- src/features/catalog/components/CatalogCourse.tsx | 10 ++++++---- src/features/catalog/components/CatalogResults.tsx | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/features/catalog/components/CatalogCourse.tsx b/src/features/catalog/components/CatalogCourse.tsx index cf5515de..2290fa51 100644 --- a/src/features/catalog/components/CatalogCourse.tsx +++ b/src/features/catalog/components/CatalogCourse.tsx @@ -68,10 +68,12 @@ const Course: React.FC = ({ course }) => {
- + {isDesktop && ( + + )}
diff --git a/src/features/catalog/components/CatalogResults.tsx b/src/features/catalog/components/CatalogResults.tsx index d14449dd..a919468c 100644 --- a/src/features/catalog/components/CatalogResults.tsx +++ b/src/features/catalog/components/CatalogResults.tsx @@ -19,7 +19,9 @@ export default function CatalogResults() { return (
{searchResults.length > 0 ? ( -
+
{searchResults.map((course, index) => ( ))} From 91586b73afcd6e84cbb66f4462b6f84e95d10d79 Mon Sep 17 00:00:00 2001 From: Gavin Liu <85533492+liug88@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:09:37 -0400 Subject: [PATCH 12/17] fix(dnd): fix overlay delay, semester shadow, and catalog placeholder opacity - Extract CatalogDragOverlay into a child component of DragDropProvider that reads useDragOperation directly, eliminating the React state cycle that caused a visible delay before the drag overlay appeared - Expose the in-flight catalog planner course ID via CatalogDragContext so Course.tsx can apply opacity-0 to the inserted placeholder, matching the ghost preview behavior of toolbox-to-planner drags - Suppress the semester dropzone bg-black/5 highlight during catalog drags; the highlight was absent for toolbox drags because those transition to targeting individual planner-course sortables, but catalog drags always kept the semester droppable as the active target --- src/components/course/Course.tsx | 7 +- src/features/dnd/CatalogDragContext.ts | 7 ++ src/features/dnd/dnd.tsx | 65 ++++++++++++------- .../components/semester/SemesterBlock.tsx | 8 ++- 4 files changed, 60 insertions(+), 27 deletions(-) create mode 100644 src/features/dnd/CatalogDragContext.ts diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index 8201de2f..b405ab29 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 { useCatalogDragId } from "@/features/dnd/CatalogDragContext"; import { SortableItemProps } from "@/features/dnd/props"; import CourseMenuContent from "@/features/planner/components/course/CourseMenuContent"; import CreditSelector from "@/features/planner/components/CreditSelector"; @@ -31,6 +32,8 @@ export default function Course({ semesterId = null, }: CourseProps) { const dndType = `${variant}-course`; + const catalogDragId = useCatalogDragId(); + const isCatalogPlaceholder = catalogDragId === id; const { handleRef, ref, isDragging } = useSortable({ id, @@ -56,7 +59,7 @@ export default function Course({ @@ -115,7 +118,7 @@ function PlannerCourseView({ className={cn( "relative flex justify-between bg-darkblue rounded-2xl text-carpipink gap-4 px-2 py-3", "hover:shadow-lg", - isDragging ? "cursor-grabbing" : "cursor-grab", + isDragging ? "cursor-grabbing opacity-0" : "cursor-grab", )} >
diff --git a/src/features/dnd/CatalogDragContext.ts b/src/features/dnd/CatalogDragContext.ts new file mode 100644 index 00000000..66dcbe8d --- /dev/null +++ b/src/features/dnd/CatalogDragContext.ts @@ -0,0 +1,7 @@ +import { createContext, useContext } from "react"; + +export const CatalogDragContext = createContext(null); + +export function useCatalogDragId() { + return useContext(CatalogDragContext); +} diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index dc350d10..4ccc719e 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -5,6 +5,7 @@ import { DragDropProvider, DragDropEventHandlers, DragOverlay, + useDragOperation, } from "@dnd-kit/react"; import { isSortable } from "@dnd-kit/react/sortable"; import { MdDragIndicator, MdOutlineMoreHoriz } from "react-icons/md"; @@ -12,6 +13,7 @@ import { v4 as uuidv4 } from "uuid"; import CourseLabel from "@/components/course/CourseLabel"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; +import { CatalogDragContext } from "@/features/dnd/CatalogDragContext"; import { APICourse, UserCourse } from "@/lib/types"; export default function WorkspaceDndProvider({ @@ -37,8 +39,10 @@ export default function WorkspaceDndProvider({ const snapshotPlanner = useRef(structuredClone(plannerCourses)); const catalogDragCourseIdRef = useRef(""); - const [draggedCatalogCourse, setDraggedCatalogCourse] = - useState(null); + // Exposed via context so Course.tsx can make the in-flight placeholder transparent + const [catalogDragCourseId, setCatalogDragCourseId] = useState( + null, + ); const [sensors] = useState(() => [ PointerSensor.configure({ @@ -74,8 +78,9 @@ export default function WorkspaceDndProvider({ snapshotPlanner.current = structuredClone(plannerCourses); snapshotToolbox.current = structuredClone(toolboxCourses); if (source?.data?.type === "catalog-course") { - setDraggedCatalogCourse(source.data.course as APICourse); - catalogDragCourseIdRef.current = uuidv4(); + const newId = uuidv4(); + catalogDragCourseIdRef.current = newId; + setCatalogDragCourseId(newId); } }, [plannerCourses, toolboxCourses], @@ -244,7 +249,7 @@ export default function WorkspaceDndProvider({ const handleDragEnd = useCallback( (event) => { - setDraggedCatalogCourse(null); + setCatalogDragCourseId(null); const { source, target } = event.operation; @@ -315,24 +320,38 @@ export default function WorkspaceDndProvider({ ); return ( - - {children} - - {draggedCatalogCourse && ( -
-
- - -
- + + + {children} + + + + ); +} + +function CatalogDragOverlay() { + const operation = useDragOperation(); + const apiCourse = + operation?.source?.data?.type === "catalog-course" + ? (operation.source.data.course as APICourse) + : null; + + return ( + + {apiCourse && ( +
+
+ +
- )} - - + +
+ )} +
); } diff --git a/src/features/planner/components/semester/SemesterBlock.tsx b/src/features/planner/components/semester/SemesterBlock.tsx index acc09742..c5d8db80 100644 --- a/src/features/planner/components/semester/SemesterBlock.tsx +++ b/src/features/planner/components/semester/SemesterBlock.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef, useEffect } from "react"; import { CollisionPriority } from "@dnd-kit/abstract"; import { closestCenter } from "@dnd-kit/collision"; -import { useDroppable } from "@dnd-kit/react"; +import { useDroppable, useDragOperation } from "@dnd-kit/react"; import { useSortable } from "@dnd-kit/react/sortable"; import { AnimatePresence } from "framer-motion"; import { MdDragIndicator, MdDeleteOutline } from "react-icons/md"; @@ -38,6 +38,10 @@ export default function SemesterBlock({ semester, index }: SemesterBlockProps) { collisionDetector: closestCenter, }); + const dragOperation = useDragOperation(); + const isDraggingCatalog = + dragOperation?.source?.data?.type === "catalog-course"; + // --- DROPPABLE: For receiving Courses inside the Semester --- const { ref: droppableRef, isDropTarget } = useDroppable({ id: `dropzone-${semester.semesterID}`, @@ -198,7 +202,7 @@ export default function SemesterBlock({ semester, index }: SemesterBlockProps) { ref={droppableRef} className={cn( "flex flex-col gap-2 h-full min-h-15 rounded-xl transition-colors", - isDropTarget && "bg-black/5", + isDropTarget && !isDraggingCatalog && "bg-black/5", )} > {semester.courseList.length === 0 ? ( From 1965e4d0abc96baeb39f6db96320b82499a6bd50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:52:40 +0000 Subject: [PATCH 13/17] fix(dnd): fix localStorage reset, add catalog-to-toolbox drag support - Fix: catalog drags no longer reset the planner on drop; only an explicit Escape cancels and undoes the placed placeholder - Add: catalog courses can now be dragged directly into the Toolbox; hovering over the toolbox removes the planner placeholder for visual clarity, and confirming the drop smart-adds the course to the toolbox (increments count for duplicates) - Also include catalog-course in the Toolbox's accept list Agent-Logs-Url: https://github.com/Project-CARPI/site/sessions/294eba98-1f9d-4cc4-9196-c142d01155a5 Co-authored-by: liug88 <85533492+liug88@users.noreply.github.com> --- package-lock.json | 18 +-------- src/features/dnd/dnd.tsx | 63 +++++++++++++++++++++++++++++--- src/features/toolbox/Toolbox.tsx | 2 +- 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5eee02fc..6240b42f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2537,7 +2536,6 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2562,7 +2560,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2574,7 +2571,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2624,7 +2620,6 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2826,7 +2821,6 @@ "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", @@ -3172,7 +3166,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3310,7 +3303,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3685,7 +3677,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4994,7 +4985,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5094,7 +5084,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -5107,7 +5096,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5455,8 +5443,7 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.2", @@ -5525,7 +5512,6 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5572,7 +5558,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5703,7 +5688,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index 4ccc719e..dba57b0b 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -26,6 +26,7 @@ export default function WorkspaceDndProvider({ toolboxCourses, moveSemester, addCourseToSemester, + addCourseToToolbox, removeCourseFromSemester, removeCourseFromToolbox, resetPlanner, @@ -106,17 +107,33 @@ export default function WorkspaceDndProvider({ const sourceType = source.data?.type; const targetType = target.data?.type; - // --- CATALOG TO PLANNER --- + // --- CATALOG TO PLANNER / TOOLBOX --- if (sourceType === "catalog-course") { const apiCourse = source.data?.course as APICourse | undefined; if (!apiCourse) return; + const plannerCourseId = catalogDragCourseIdRef.current; + const existingLocation = courseLocationMap.get(plannerCourseId); + const targetSemesterId = targetType === "semester" ? target.data?.semesterId || targetId : courseLocationMap.get(targetId)?.semesterId; - if (!targetSemesterId) return; + if (!targetSemesterId) { + // Hovering over toolbox — remove the placeholder from the planner + // so the user sees it leave the semester + if ( + (targetId === "toolbox" || targetType === "toolbox-course") && + existingLocation + ) { + removeCourseFromSemester( + existingLocation.semesterId, + plannerCourseId, + ); + } + return; + } let catalogTargetIndex: number | undefined; if (isSortable(target)) { @@ -129,8 +146,6 @@ export default function WorkspaceDndProvider({ catalogTargetIndex = targetSemester?.courseList.length ?? 0; } - const plannerCourseId = catalogDragCourseIdRef.current; - const existingLocation = courseLocationMap.get(plannerCourseId); const courseToPlace: UserCourse = { id: plannerCourseId, name: `${apiCourse.subj_code} ${apiCourse.code_num}`, @@ -252,6 +267,44 @@ export default function WorkspaceDndProvider({ setCatalogDragCourseId(null); const { source, target } = event.operation; + const sourceType = source?.data?.type; + + // --- CATALOG DRAG END --- + // Catalog placements are committed incrementally in handleDragOver, so we + // only need to undo on an explicit cancel (Escape), handle a toolbox drop, + // or otherwise leave the placed course in the planner as-is. + if (sourceType === "catalog-course") { + const plannerCourseId = catalogDragCourseIdRef.current; + const apiCourse = source?.data?.course as APICourse | undefined; + const targetId = target?.id as string | undefined; + const targetType = target?.data?.type; + + if (event.canceled) { + resetPlanner(snapshotPlanner.current); + return; + } + + if (targetId === "toolbox" || targetType === "toolbox-course") { + // Remove the temporarily placed planner placeholder, if any + const placedLocation = courseLocationMap.get(plannerCourseId); + if (placedLocation) { + removeCourseFromSemester( + placedLocation.semesterId, + plannerCourseId, + ); + } + // Smart-add to toolbox (increments count for duplicates) + if (apiCourse) { + addCourseToToolbox(apiCourse); + } + consolidateToolbox(); + return; + } + + // Successful planner drop or released outside — course is already + // committed to the semester via handleDragOver; nothing more to do. + return; + } if (event.canceled || !target) { resetPlanner(snapshotPlanner.current); @@ -262,7 +315,6 @@ export default function WorkspaceDndProvider({ const sourceId = source.id as string; const targetId = target.id as string; - const sourceType = source.data?.type; const targetType = target.data?.type; // --- UTILITY DROPZONES --- @@ -312,6 +364,7 @@ export default function WorkspaceDndProvider({ resetPlanner, resetToolbox, consolidateToolbox, + addCourseToToolbox, courseLocationMap, removeCourseFromSemester, removeCourseFromToolbox, diff --git a/src/features/toolbox/Toolbox.tsx b/src/features/toolbox/Toolbox.tsx index 3a2d09a9..c869fcdd 100644 --- a/src/features/toolbox/Toolbox.tsx +++ b/src/features/toolbox/Toolbox.tsx @@ -18,7 +18,7 @@ export default function Toolbox() { const { ref } = useDroppable({ id: "toolbox", type: "toolbox", - accept: ["planner-course", "toolbox-course"], + accept: ["planner-course", "toolbox-course", "catalog-course"], collisionPriority: CollisionPriority.High, collisionDetector: pointerIntersection, }); From aa5f21354307466deef16109b66642483374b37f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:57:43 +0000 Subject: [PATCH 14/17] fix(dnd): replace em dash with double hyphen in comment Agent-Logs-Url: https://github.com/Project-CARPI/site/sessions/294eba98-1f9d-4cc4-9196-c142d01155a5 Co-authored-by: liug88 <85533492+liug88@users.noreply.github.com> --- src/features/dnd/dnd.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index dba57b0b..df70d08b 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -301,7 +301,7 @@ export default function WorkspaceDndProvider({ return; } - // Successful planner drop or released outside — course is already + // Successful planner drop or released outside -- course is already // committed to the semester via handleDragOver; nothing more to do. return; } From 0809a0451539e54bf84890af6d8f36023ac9f0d0 Mon Sep 17 00:00:00 2001 From: liug88 <85533492+liug88@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:31:52 -0400 Subject: [PATCH 15/17] Changing Opacity Hopefully, this should fix the rest of the changes. --- src/components/course/Course.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index 905879b4..c139597c 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -130,7 +130,7 @@ function PlannerCourseView({ className={cn( "relative flex justify-between bg-darkblue rounded-2xl text-carpipink gap-4 px-2 py-3", "hover:shadow-lg", - isDragging ? "cursor-grabbing opacity-0" : "cursor-grab", + isDragging ? "cursor-grabbing opacity-50" : "cursor-grab", )} >
From f47f0dbbdcf5368845fc080b81920e89b0771bb2 Mon Sep 17 00:00:00 2001 From: liug88 <85533492+liug88@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:19:30 -0400 Subject: [PATCH 16/17] refactor(dnd): unify catalog-drag placeholder with toolbox ghost contract Addresses the review comment about the catalog->planner and toolbox->planner drag paths diverging visually. The catalog drag mounts a real into planner state during hover as the in-flight placeholder. Previously that element was styled with ad-hoc Tailwind (opacity-50, pointer-events-none, and conditional hover:shadow-lg suppression) to mimic the toolbox ghost. The toolbox path gets the same look for free from dnd-kit, which emits this contract on its placeholder clones: data-dnd-placeholder="clone" inert aria-hidden="true" tabindex="-1" That contract, combined with the `[data-dnd-placeholder="clone"] { opacity: 0.5 !important }` rule already present in index.css and the `inert` attribute, handles all four concerns uniformly: 0.5 opacity, no pointer events, no hover shadow, no keyboard focus. This commit makes PlannerCourseView emit the same attributes when rendering as the catalog placeholder (new `isCatalogPlaceholder` prop) and drops the Tailwind overrides. The two flows now share the same rendering contract -- inspect the DOM mid-drag and the two placeholders are identical in attributes, driven by the same CSS rule. The split between useSortable (toolbox) and useDraggable (catalog) stays intentional -- they describe genuinely different data sources (stateful list item vs. read-only template) -- but the rendering layer now converges. Co-Authored-By: Claude Opus 4.7 --- src/components/course/Course.tsx | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index c139597c..222ef216 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, type HTMLAttributes } from "react"; import { useSortable } from "@dnd-kit/react/sortable"; import * as ContextMenu from "@radix-ui/react-context-menu"; @@ -60,7 +60,8 @@ export default function Course({ @@ -71,6 +72,7 @@ type ViewProps = { innerRef?: (element: HTMLElement | null) => void; handleRef?: (element: HTMLElement | null) => void; isDragging: boolean; + isCatalogPlaceholder?: boolean; course: UserCourse; semesterId?: string | null; }; @@ -92,9 +94,30 @@ function PlannerCourseView({ innerRef, handleRef, isDragging, + isCatalogPlaceholder = false, course, semesterId, }: ViewProps) { + // Catalog drags mount a real as the in-flight placeholder. dnd-kit + // doesn't know about that element, so we manually apply the same contract it + // uses for its own sortable placeholders (`data-dnd-placeholder="clone"` + + // `inert` + `aria-hidden` + `tabIndex=-1`). That lets the existing CSS rule + // (`[data-dnd-placeholder="clone"] { opacity: 0.5 !important }`) and `inert` + // handle the ghost appearance and interaction-suppression uniformly -- the + // same way the toolbox-originated placeholder works out of the box. + // `inert` typing wasn't added until React 19, hence the cast. + const placeholderAttrs = ( + isCatalogPlaceholder + ? { + "data-dnd-placeholder": "clone", + inert: "", + "aria-hidden": true, + tabIndex: -1, + } + : {} + ) as HTMLAttributes; + + const isGhost = isDragging || isCatalogPlaceholder; const menuOptions = usePlannerCourse({ course, semesterId: semesterId ?? null, @@ -118,6 +141,7 @@ function PlannerCourseView({
{ if ( typeof window !== "undefined" && @@ -130,7 +154,7 @@ function PlannerCourseView({ className={cn( "relative flex justify-between bg-darkblue rounded-2xl text-carpipink gap-4 px-2 py-3", "hover:shadow-lg", - isDragging ? "cursor-grabbing opacity-50" : "cursor-grab", + isGhost ? "cursor-grabbing" : "cursor-grab", )} >
From 8bee519c23a9fa4e71badcf0656942ebfa58641e Mon Sep 17 00:00:00 2001 From: liug88 <85533492+liug88@users.noreply.github.com> Date: Fri, 24 Apr 2026 12:58:56 -0400 Subject: [PATCH 17/17] Deleting Unnecessary Context and refactoring DND This should be the final fix for the over using the DragDropProvider. --- src/components/course/Course.tsx | 61 +++++++---- src/features/dnd/CatalogDragContext.ts | 7 -- src/features/dnd/dnd.tsx | 135 +++++++++++++------------ 3 files changed, 113 insertions(+), 90 deletions(-) delete mode 100644 src/features/dnd/CatalogDragContext.ts diff --git a/src/components/course/Course.tsx b/src/components/course/Course.tsx index 222ef216..b1dae4e9 100644 --- a/src/components/course/Course.tsx +++ b/src/components/course/Course.tsx @@ -1,5 +1,6 @@ import { useState, type HTMLAttributes } from "react"; +import { useDragOperation } from "@dnd-kit/react"; import { useSortable } from "@dnd-kit/react/sortable"; import * as ContextMenu from "@radix-ui/react-context-menu"; import * as Popover from "@radix-ui/react-popover"; @@ -7,7 +8,6 @@ import { MdOutlineMoreHoriz, MdDragIndicator } from "react-icons/md"; import CourseBadge from "@/components/course/CourseBadge"; import CourseLabel from "@/components/course/CourseLabel"; -import { useCatalogDragId } from "@/features/dnd/CatalogDragContext"; import { SortableItemProps } from "@/features/dnd/props"; import CourseMenuContent from "@/features/planner/components/course/CourseMenuContent"; import CreditSelector from "@/features/planner/components/CreditSelector"; @@ -33,8 +33,15 @@ export default function Course({ semesterId = null, }: CourseProps) { const dndType = `${variant}-course`; - const catalogDragId = useCatalogDragId(); - const isCatalogPlaceholder = catalogDragId === id; + + // The in-flight node for a catalog drag is rendered by us (not dnd-kit's + // sortable ghost), so read the active drag operation and flag ourselves as + // the placeholder when our id matches the placeholderId stashed on the + // source in handleDragStart. + const operation = useDragOperation(); + const isCatalogPlaceholder = + operation?.source?.data?.type === "catalog-course" && + operation.source.data.placeholderId === id; const { handleRef, ref, isDragging } = useSortable({ id, @@ -51,6 +58,7 @@ export default function Course({ ); @@ -77,10 +85,16 @@ type ViewProps = { semesterId?: string | null; }; -function ToolboxCourseView({ innerRef, isDragging, course }: ViewProps) { +function ToolboxCourseView({ + innerRef, + isDragging, + isCatalogPlaceholder = false, + course, +}: ViewProps) { return (
@@ -90,23 +104,18 @@ function ToolboxCourseView({ innerRef, isDragging, course }: ViewProps) { ); } -function PlannerCourseView({ - innerRef, - handleRef, - isDragging, - isCatalogPlaceholder = false, - course, - semesterId, -}: ViewProps) { - // Catalog drags mount a real as the in-flight placeholder. dnd-kit - // doesn't know about that element, so we manually apply the same contract it - // uses for its own sortable placeholders (`data-dnd-placeholder="clone"` + - // `inert` + `aria-hidden` + `tabIndex=-1`). That lets the existing CSS rule - // (`[data-dnd-placeholder="clone"] { opacity: 0.5 !important }`) and `inert` - // handle the ghost appearance and interaction-suppression uniformly -- the - // same way the toolbox-originated placeholder works out of the box. - // `inert` typing wasn't added until React 19, hence the cast. - const placeholderAttrs = ( +// Catalog drags mount a real as the in-flight placeholder. dnd-kit +// doesn't know about that element, so we manually apply the same contract it +// uses for its own sortable placeholders (`data-dnd-placeholder="clone"` + +// `inert` + `aria-hidden` + `tabIndex=-1`). That lets the existing CSS rule +// (`[data-dnd-placeholder="clone"] { opacity: 0.5 !important }`) and `inert` +// handle the ghost appearance and interaction-suppression uniformly -- the +// same way the toolbox-originated placeholder works out of the box. +// `inert` typing wasn't added until React 19, hence the cast. +function getCatalogPlaceholderAttrs( + isCatalogPlaceholder: boolean, +): HTMLAttributes { + return ( isCatalogPlaceholder ? { "data-dnd-placeholder": "clone", @@ -116,7 +125,17 @@ function PlannerCourseView({ } : {} ) as HTMLAttributes; +} +function PlannerCourseView({ + innerRef, + handleRef, + isDragging, + isCatalogPlaceholder = false, + course, + semesterId, +}: ViewProps) { + const placeholderAttrs = getCatalogPlaceholderAttrs(isCatalogPlaceholder); const isGhost = isDragging || isCatalogPlaceholder; const menuOptions = usePlannerCourse({ course, diff --git a/src/features/dnd/CatalogDragContext.ts b/src/features/dnd/CatalogDragContext.ts deleted file mode 100644 index 66dcbe8d..00000000 --- a/src/features/dnd/CatalogDragContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createContext, useContext } from "react"; - -export const CatalogDragContext = createContext(null); - -export function useCatalogDragId() { - return useContext(CatalogDragContext); -} diff --git a/src/features/dnd/dnd.tsx b/src/features/dnd/dnd.tsx index df70d08b..f8b50037 100644 --- a/src/features/dnd/dnd.tsx +++ b/src/features/dnd/dnd.tsx @@ -13,7 +13,6 @@ import { v4 as uuidv4 } from "uuid"; import CourseLabel from "@/components/course/CourseLabel"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; -import { CatalogDragContext } from "@/features/dnd/CatalogDragContext"; import { APICourse, UserCourse } from "@/lib/types"; export default function WorkspaceDndProvider({ @@ -34,16 +33,11 @@ export default function WorkspaceDndProvider({ consolidateToolbox, moveCourseInSemester, insertCourseIntoToolbox, + moveCourseInToolbox, } = useCourseWorkspace(); const snapshotToolbox = useRef(structuredClone(toolboxCourses)); const snapshotPlanner = useRef(structuredClone(plannerCourses)); - const catalogDragCourseIdRef = useRef(""); - - // Exposed via context so Course.tsx can make the in-flight placeholder transparent - const [catalogDragCourseId, setCatalogDragCourseId] = useState( - null, - ); const [sensors] = useState(() => [ PointerSensor.configure({ @@ -79,9 +73,10 @@ export default function WorkspaceDndProvider({ snapshotPlanner.current = structuredClone(plannerCourses); snapshotToolbox.current = structuredClone(toolboxCourses); if (source?.data?.type === "catalog-course") { - const newId = uuidv4(); - catalogDragCourseIdRef.current = newId; - setCatalogDragCourseId(newId); + // Mint a fresh placeholder id per drag and stash it on source.data so + // any component reading useDragOperation() can identify the in-flight + // placeholder without a parallel context. + source.data = { ...source.data, placeholderId: uuidv4() }; } }, [plannerCourses, toolboxCourses], @@ -110,31 +105,63 @@ export default function WorkspaceDndProvider({ // --- CATALOG TO PLANNER / TOOLBOX --- if (sourceType === "catalog-course") { const apiCourse = source.data?.course as APICourse | undefined; - if (!apiCourse) return; + const placeholderId = source.data?.placeholderId as string | undefined; + if (!apiCourse || !placeholderId) return; - const plannerCourseId = catalogDragCourseIdRef.current; - const existingLocation = courseLocationMap.get(plannerCourseId); + const existingLocation = courseLocationMap.get(placeholderId); + const toolboxIndex = toolboxCourses.findIndex( + (c) => c.id === placeholderId, + ); + const isInToolbox = toolboxIndex !== -1; - const targetSemesterId = - targetType === "semester" - ? target.data?.semesterId || targetId - : courseLocationMap.get(targetId)?.semesterId; + const courseToPlace: UserCourse = { + id: placeholderId, + name: `${apiCourse.subj_code} ${apiCourse.code_num}`, + count: 1, + credits: apiCourse.credit_max, + data: apiCourse, + }; - if (!targetSemesterId) { - // Hovering over toolbox — remove the placeholder from the planner - // so the user sees it leave the semester - if ( - (targetId === "toolbox" || targetType === "toolbox-course") && - existingLocation - ) { + const isToolboxTarget = + targetId === "toolbox" || targetType === "toolbox-course"; + + // --- CATALOG OVER TOOLBOX: maintain a ghost entry in the toolbox --- + if (isToolboxTarget) { + // Pull the placeholder out of the planner if it's there + if (existingLocation) { removeCourseFromSemester( existingLocation.semesterId, - plannerCourseId, + placeholderId, ); } + + const targetToolboxIndex = isSortable(target) + ? target.index + : toolboxCourses.length; + + if (!isInToolbox) { + insertCourseIntoToolbox(courseToPlace, targetToolboxIndex); + } else if ( + toolboxIndex !== targetToolboxIndex && + targetToolboxIndex !== undefined + ) { + moveCourseInToolbox(toolboxIndex, targetToolboxIndex); + } return; } + const targetSemesterId = + targetType === "semester" + ? target.data?.semesterId || targetId + : courseLocationMap.get(targetId)?.semesterId; + + if (!targetSemesterId) return; + + // Moving out of the toolbox ghost into a semester + if (isInToolbox) { + removeCourseFromToolbox(placeholderId); + } + let catalogTargetIndex: number | undefined; if (isSortable(target)) { catalogTargetIndex = target.index; @@ -146,14 +173,6 @@ export default function WorkspaceDndProvider({ catalogTargetIndex = targetSemester?.courseList.length ?? 0; } - const courseToPlace: UserCourse = { - id: plannerCourseId, - name: `${apiCourse.subj_code} ${apiCourse.code_num}`, - count: 1, - credits: apiCourse.credit_max, - data: apiCourse, - }; - if (!existingLocation) { addCourseToSemester( targetSemesterId, @@ -174,10 +193,7 @@ export default function WorkspaceDndProvider({ ); } } else { - removeCourseFromSemester( - existingLocation.semesterId, - plannerCourseId, - ); + removeCourseFromSemester(existingLocation.semesterId, placeholderId); addCourseToSemester( targetSemesterId, courseToPlace, @@ -253,19 +269,19 @@ export default function WorkspaceDndProvider({ }, [ plannerCourses, + toolboxCourses, addCourseToSemester, removeCourseFromToolbox, removeCourseFromSemester, courseLocationMap, moveCourseInSemester, insertCourseIntoToolbox, + moveCourseInToolbox, ], ); const handleDragEnd = useCallback( (event) => { - setCatalogDragCourseId(null); - const { source, target } = event.operation; const sourceType = source?.data?.type; @@ -274,29 +290,25 @@ export default function WorkspaceDndProvider({ // only need to undo on an explicit cancel (Escape), handle a toolbox drop, // or otherwise leave the placed course in the planner as-is. if (sourceType === "catalog-course") { - const plannerCourseId = catalogDragCourseIdRef.current; + const placeholderId = source?.data?.placeholderId as string | undefined; const apiCourse = source?.data?.course as APICourse | undefined; const targetId = target?.id as string | undefined; const targetType = target?.data?.type; if (event.canceled) { resetPlanner(snapshotPlanner.current); + resetToolbox(snapshotToolbox.current); return; } if (targetId === "toolbox" || targetType === "toolbox-course") { - // Remove the temporarily placed planner placeholder, if any - const placedLocation = courseLocationMap.get(plannerCourseId); - if (placedLocation) { - removeCourseFromSemester( - placedLocation.semesterId, - plannerCourseId, - ); - } - // Smart-add to toolbox (increments count for duplicates) - if (apiCourse) { - addCourseToToolbox(apiCourse); + // Replace the ghost toolbox entry (if any) with a smart-add so + // duplicate counts increment instead of creating a parallel row. + if (placeholderId) { + const hadGhost = toolboxCourses.some((c) => c.id === placeholderId); + if (hadGhost) removeCourseFromToolbox(placeholderId); } + if (apiCourse) addCourseToToolbox(apiCourse); consolidateToolbox(); return; } @@ -360,6 +372,7 @@ export default function WorkspaceDndProvider({ }, [ plannerCourses, + toolboxCourses, moveSemester, resetPlanner, resetToolbox, @@ -373,17 +386,15 @@ export default function WorkspaceDndProvider({ ); return ( - - - {children} - - - + + {children} + + ); }