-
-
-
-
-
+
+
+ {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}
+
+
);
}