From e309f2515a708eb5b9544188aab814960109c8dc Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:17:32 -0400 Subject: [PATCH 01/11] add radix dialog --- package-lock.json | 142 ++++++++++++++++++---------------------------- package.json | 5 +- 2 files changed, 57 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 09869ef..33837de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/helpers": "^0.3.2", "@dnd-kit/react": "^0.3.2", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/vite": "^4.0.6", "axios": "^1.7.9", @@ -79,6 +80,7 @@ "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", @@ -1292,6 +1294,42 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -1840,9 +1878,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1856,9 +1891,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1872,9 +1904,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1888,9 +1917,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1904,9 +1930,6 @@ "cpu": [ "loong64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1920,9 +1943,6 @@ "cpu": [ "loong64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1936,9 +1956,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1952,9 +1969,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1968,9 +1982,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1984,9 +1995,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2000,9 +2008,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2016,9 +2021,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2032,9 +2034,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2244,9 +2243,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2263,9 +2259,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2282,9 +2275,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2301,9 +2291,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2462,6 +2449,7 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -2486,6 +2474,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -2497,6 +2486,7 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2546,6 +2536,7 @@ "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", @@ -2747,6 +2738,7 @@ "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", @@ -2902,9 +2894,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2919,9 +2908,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2936,9 +2922,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2953,9 +2936,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2970,9 +2950,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2987,9 +2964,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3004,9 +2978,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3021,9 +2992,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3116,6 +3084,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3253,6 +3222,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3627,6 +3597,7 @@ "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", @@ -4561,9 +4532,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4584,9 +4552,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4607,9 +4572,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4630,9 +4592,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4947,6 +4906,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5046,6 +5006,7 @@ "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" }, @@ -5058,6 +5019,7 @@ "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" @@ -5405,7 +5367,8 @@ "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.2", @@ -5474,6 +5437,7 @@ "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5520,6 +5484,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -5650,6 +5615,7 @@ "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/package.json b/package.json index c86b62d..ecb1d51 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@dnd-kit/helpers": "^0.3.2", "@dnd-kit/react": "^0.3.2", "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-popover": "^1.1.15", "@tailwindcss/vite": "^4.0.6", "axios": "^1.7.9", @@ -31,8 +32,8 @@ "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "uuid": "^13.0.0", - "zustand": "^5.0.11", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.17.0", From 96a76e92acbf0ecd12347dd137d9feb098dd180d Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:17:39 -0400 Subject: [PATCH 02/11] initial dialog --- src/components/dialog.tsx | 142 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 src/components/dialog.tsx diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx new file mode 100644 index 0000000..01db074 --- /dev/null +++ b/src/components/dialog.tsx @@ -0,0 +1,142 @@ +import React, { useState, useCallback, useEffect } from "react"; + +import * as Dialog from "@radix-ui/react-dialog"; + +import { cn } from "@/lib/classnames"; + +type DialogProps = { + title: string; + description: React.ReactNode; + children: React.ReactNode; + onConfirm: () => boolean | Promise; + trigger?: React.ReactNode; + disabled?: boolean; + autoClose?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; +}; + +export default function ConfirmationDialog({ + title, + description, + children, + onConfirm, + trigger, + disabled = false, + autoClose = false, + open: controlledOpen, + onOpenChange, +}: DialogProps) { + const [internalOpen, setInternalOpen] = useState(false); + + // use controlled state if provided, otherwise local state + const isControlled = controlledOpen !== undefined; + const open = isControlled ? controlledOpen : internalOpen; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!isControlled) { + setInternalOpen(newOpen); + } + onOpenChange?.(newOpen); + }, + [isControlled, onOpenChange], + ); + + const handleClose = useCallback(() => { + handleOpenChange(false); + return true; + }, [handleOpenChange]); + + const handleConfirm = useCallback(async () => { + // If autoClose is enabled, we optimistically close + // the dialog before calling onConfirm + if (autoClose) { + handleOpenChange(false); + onConfirm(); + return true; + } + + const success = await onConfirm(); + if (success) { + handleOpenChange(false); + } + return success; + }, [autoClose, onConfirm, handleOpenChange]); + + useEffect(() => { + // If the dialog is not open, then don't add the keydown listener + if (!open) return; + + // Add keydown listener for Enter and Escape keys to trigger confirm or cancel + // actions. + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore key events coming from text inputs or editable elements + const target = e.target as HTMLElement | null; + const tagName = target?.tagName; + const isTextInput = + tagName === "INPUT" || + tagName === "TEXTAREA" || + target?.isContentEditable; + if (isTextInput) { + return; + } + + if (e.key === "Escape") handleClose(); + else if (e.key === "Enter") handleConfirm(); + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => window.removeEventListener("keydown", handleKeyDown); + }, [handleClose, handleConfirm, open]); + + return ( + + {trigger && ( + { + if (disabled) { + e.preventDefault(); + e.stopPropagation(); + } + }} + aria-disabled={disabled} + > + {trigger} + + )} + + + + +
+ +
+

{title}

+
+
+ + +
{description}
+
+ + {children} +
+
+
+
+ ); +} From ae1ed9742117f473032eb90b8d60b6fa6bacf3e3 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:32:21 -0400 Subject: [PATCH 03/11] add confirmation dialog transitions --- src/index.css | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/index.css b/src/index.css index aa8ffb3..e3823e1 100644 --- a/src/index.css +++ b/src/index.css @@ -69,3 +69,63 @@ img { height: auto !important; max-height: none !important; } + +/* Confirmation Dialog animations */ +.dialog-overlay[data-state="open"] { + animation: dialog-overlay-show 400ms ease-out; +} + +.dialog-overlay[data-state="closed"] { + animation: dialog-overlay-hide 300ms ease-in; + animation-delay: 50ms; +} + +.dialog-content[data-state="open"] { + animation: dialog-content-show 400ms cubic-bezier(0.16, 1, 0.3, 1); + animation-delay: 100ms; + animation-fill-mode: backwards; +} + +.dialog-content[data-state="closed"] { + animation: dialog-content-hide 250ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes dialog-overlay-show { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes dialog-overlay-hide { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes dialog-content-show { + from { + opacity: 0; + transform: scale(0.92) translateY(10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes dialog-content-hide { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.95) translateY(10px); + } +} From 4738cfbf72ec2de7f2c9420e12aaacd9fa2f98ec Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:38:26 -0400 Subject: [PATCH 04/11] use dialog --- src/components/dialog.tsx | 4 +- src/components/header/ButtonTray.tsx | 102 ++++++++++++++++-- src/core/workspace/provider.tsx | 6 -- src/core/workspace/utils/io/useInputOutput.ts | 44 ++------ 4 files changed, 104 insertions(+), 52 deletions(-) diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx index 01db074..3e48a7e 100644 --- a/src/components/dialog.tsx +++ b/src/components/dialog.tsx @@ -119,8 +119,8 @@ export default function ConfirmationDialog({
diff --git a/src/components/header/ButtonTray.tsx b/src/components/header/ButtonTray.tsx index 161ab1f..41867a2 100644 --- a/src/components/header/ButtonTray.tsx +++ b/src/components/header/ButtonTray.tsx @@ -8,7 +8,9 @@ import { BiDotsVerticalRounded, } from "react-icons/bi"; +import ConfirmationDialog from "@/components/dialog"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; +import { SaveFileSchema } from "@/core/workspace/utils/io/inputOutput"; import { useInputOutput } from "@/core/workspace/utils/io/useInputOutput"; import { cn } from "@/lib/classnames"; @@ -44,10 +46,13 @@ function HeaderButton({ onClick, children, tooltip }: HeaderButtonProps) { export default function ButtonTray() { const [isOpen, setIsOpen] = useState(false); + const [activeDialog, setActiveDialog] = useState<"reset" | "import" | null>( + null, + ); + const [pendingFileData, setPendingFileData] = useState(null); const { resetWorkspace } = useCourseWorkspace(); - const { exportPlan, importPlan } = useInputOutput(); - + const { exportPlan, performImport } = useInputOutput(); const fileInputRef = useRef(null); const handleImportClick = () => { @@ -57,20 +62,58 @@ export default function ButtonTray() { const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; - if (file) { - importPlan(file); + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const json = JSON.parse(event.target?.result as string); + const result = SaveFileSchema.safeParse(json); + + if (!result.success) { + // You could use another dialog for errors, but for now: + alert("Invalid file format."); + return; + } + + setPendingFileData(result.data); + setActiveDialog("import"); + } catch { + alert("Failed to parse file."); + } + }; + reader.readAsText(file); + e.target.value = ""; // Reset input + }; + + const confirmReset = () => { + resetWorkspace(); + setActiveDialog(null); + return true; + }; + + const confirmImport = () => { + if (pendingFileData) { + performImport(pendingFileData); } - e.target.value = ""; + setPendingFileData(null); + setActiveDialog(null); + return true; }; const buttonList = ( <> - + setActiveDialog("reset")} + tooltip="Reset Workspace" + > + + @@ -115,6 +158,53 @@ export default function ButtonTray() {
+ + !open && setActiveDialog(null)} + description="Are you sure you want to reset your entire workspace? This will delete all semesters and courses." + onConfirm={confirmReset} + > +
+ + +
+
+ + {/* IMPORT DIALOG */} + !open && setActiveDialog(null)} + description="Importing will overwrite your current plan. This action cannot be undone." + onConfirm={confirmImport} + > +
+ + +
+
); } diff --git a/src/core/workspace/provider.tsx b/src/core/workspace/provider.tsx index 366d07a..4e9429f 100644 --- a/src/core/workspace/provider.tsx +++ b/src/core/workspace/provider.tsx @@ -82,12 +82,6 @@ export function CourseWorkspaceProvider({ children }: { children: ReactNode }) { const toolboxActions = useToolboxActions(dispatchToolbox, toolboxCourses); const resetWorkspace = useCallback(() => { - // confirmation dialog to prevent accidental resets - const confirmed = window.confirm( - "Are you sure you want to reset your entire workspace? This will delete all semesters and courses in your toolbox.", - ); - if (!confirmed) return; - const emptyPlanner = createInitialPlannerState(6); plannerActions.resetPlanner(emptyPlanner); toolboxActions.resetToolbox([]); diff --git a/src/core/workspace/utils/io/useInputOutput.ts b/src/core/workspace/utils/io/useInputOutput.ts index fcc719b..f5405a8 100644 --- a/src/core/workspace/utils/io/useInputOutput.ts +++ b/src/core/workspace/utils/io/useInputOutput.ts @@ -4,7 +4,6 @@ import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; import { cleanCourseForExport, enrichPlannerForImport, - SaveFileSchema, } from "@/core/workspace/utils/io/inputOutput"; export const useInputOutput = () => { @@ -41,45 +40,14 @@ export const useInputOutput = () => { }, [plannerCourses]); /* IMPORT */ - const importPlan = useCallback( - (file: File) => { - const reader = new FileReader(); - - reader.onload = (event) => { - try { - const json = JSON.parse(event.target?.result as string); - - // Validate against schema - const result = SaveFileSchema.safeParse(json); - if (!result.success) { - alert("Error: The selected file is not a valid Carpi plan."); - console.error(result.error); - return; - } - - if ( - !confirm( - "Importing will overwrite your current plan. Are you sure?", - ) - ) { - return; - } - - // Clean Data and Update State - const importedPlanner = enrichPlannerForImport(result.data.planner); - - resetPlanner(importedPlanner); - resetToolbox([]); - } catch (err) { - console.error(err); - alert("Failed to parse file."); - } - }; - - reader.readAsText(file); + const performImport = useCallback( + (jsonData: any) => { + const importedPlanner = enrichPlannerForImport(jsonData.planner); + resetPlanner(importedPlanner); + resetToolbox([]); }, [resetPlanner, resetToolbox], ); - return { exportPlan, importPlan }; + return { exportPlan, performImport }; }; From c7a8025eae8cbeee3767332367d961562b61e8c3 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:05:26 -0400 Subject: [PATCH 05/11] rename dialog from confirmationDialog --- src/components/dialog.tsx | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx index 3e48a7e..d2ec67a 100644 --- a/src/components/dialog.tsx +++ b/src/components/dialog.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useEffect } from "react"; -import * as Dialog from "@radix-ui/react-dialog"; +import * as RadixDialog from "@radix-ui/react-dialog"; import { cn } from "@/lib/classnames"; @@ -16,7 +16,7 @@ type DialogProps = { onOpenChange?: (open: boolean) => void; }; -export default function ConfirmationDialog({ +export default function Dialog({ title, description, children, @@ -92,9 +92,9 @@ export default function ConfirmationDialog({ }, [handleClose, handleConfirm, open]); return ( - + {trigger && ( - { @@ -106,16 +106,16 @@ export default function ConfirmationDialog({ aria-disabled={disabled} > {trigger} - + )} - - + - +
- +

{title}

-
+ - +
{description}
-
+ {children}
-
-
-
+ + + ); } From 8fc80467054f0169117ded65747eaff133f9fb0b Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:05:41 -0400 Subject: [PATCH 06/11] define types for save file data --- src/components/header/ButtonTray.tsx | 21 +++++++++++-------- src/core/workspace/utils/io/useInputOutput.ts | 7 ++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/header/ButtonTray.tsx b/src/components/header/ButtonTray.tsx index 41867a2..964096f 100644 --- a/src/components/header/ButtonTray.tsx +++ b/src/components/header/ButtonTray.tsx @@ -8,9 +8,12 @@ import { BiDotsVerticalRounded, } from "react-icons/bi"; -import ConfirmationDialog from "@/components/dialog"; +import Dialog from "@/components/dialog"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; -import { SaveFileSchema } from "@/core/workspace/utils/io/inputOutput"; +import { + SaveFile, + SaveFileSchema, +} from "@/core/workspace/utils/io/inputOutput"; import { useInputOutput } from "@/core/workspace/utils/io/useInputOutput"; import { cn } from "@/lib/classnames"; @@ -49,10 +52,10 @@ export default function ButtonTray() { const [activeDialog, setActiveDialog] = useState<"reset" | "import" | null>( null, ); - const [pendingFileData, setPendingFileData] = useState(null); + const [pendingFileData, setPendingFileData] = useState(null); const { resetWorkspace } = useCourseWorkspace(); - const { exportPlan, performImport } = useInputOutput(); + const { exportPlan, importPlan } = useInputOutput(); const fileInputRef = useRef(null); const handleImportClick = () => { @@ -94,7 +97,7 @@ export default function ButtonTray() { const confirmImport = () => { if (pendingFileData) { - performImport(pendingFileData); + importPlan(pendingFileData); } setPendingFileData(null); setActiveDialog(null); @@ -159,7 +162,7 @@ export default function ButtonTray() { - !open && setActiveDialog(null)} @@ -180,10 +183,10 @@ export default function ButtonTray() { Confirm Reset - + {/* IMPORT DIALOG */} - !open && setActiveDialog(null)} @@ -204,7 +207,7 @@ export default function ButtonTray() { Overwrite & Import - + ); } diff --git a/src/core/workspace/utils/io/useInputOutput.ts b/src/core/workspace/utils/io/useInputOutput.ts index f5405a8..4cef402 100644 --- a/src/core/workspace/utils/io/useInputOutput.ts +++ b/src/core/workspace/utils/io/useInputOutput.ts @@ -4,6 +4,7 @@ import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; import { cleanCourseForExport, enrichPlannerForImport, + SaveFile, } from "@/core/workspace/utils/io/inputOutput"; export const useInputOutput = () => { @@ -40,8 +41,8 @@ export const useInputOutput = () => { }, [plannerCourses]); /* IMPORT */ - const performImport = useCallback( - (jsonData: any) => { + const importPlan = useCallback( + (jsonData: SaveFile) => { const importedPlanner = enrichPlannerForImport(jsonData.planner); resetPlanner(importedPlanner); resetToolbox([]); @@ -49,5 +50,5 @@ export const useInputOutput = () => { [resetPlanner, resetToolbox], ); - return { exportPlan, performImport }; + return { exportPlan, importPlan }; }; From 06a0727cff17603b722bfa6b10c2686596b38e99 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:10:10 -0400 Subject: [PATCH 07/11] update z-index of dialog --- src/components/dialog.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx index d2ec67a..c8bceca 100644 --- a/src/components/dialog.tsx +++ b/src/components/dialog.tsx @@ -112,13 +112,13 @@ export default function Dialog({
Date: Mon, 6 Apr 2026 19:10:28 -0400 Subject: [PATCH 08/11] close button tray when clicking on a button inside --- src/components/header/ButtonTray.tsx | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/components/header/ButtonTray.tsx b/src/components/header/ButtonTray.tsx index 964096f..87f94bf 100644 --- a/src/components/header/ButtonTray.tsx +++ b/src/components/header/ButtonTray.tsx @@ -107,17 +107,32 @@ export default function ButtonTray() { const buttonList = ( <> setActiveDialog("reset")} + onClick={() => { + setActiveDialog("reset"); + setIsOpen(false); + }} tooltip="Reset Workspace" > - + { + exportPlan(); + setIsOpen(false); + }} + tooltip="Export Plan" + > - + { + handleImportClick(); + setIsOpen(false); + }} + tooltip="Import Plan" + > From d29e2f1db8887a97aaba5674710ba848488aa402 Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:21:05 -0400 Subject: [PATCH 09/11] remove header button into own component folder --- src/components/header/ButtonTray.tsx | 67 ++++++++++++-------------- src/components/header/HeaderButton.tsx | 35 ++++++++++++++ 2 files changed, 65 insertions(+), 37 deletions(-) create mode 100644 src/components/header/HeaderButton.tsx diff --git a/src/components/header/ButtonTray.tsx b/src/components/header/ButtonTray.tsx index 87f94bf..e51e636 100644 --- a/src/components/header/ButtonTray.tsx +++ b/src/components/header/ButtonTray.tsx @@ -8,7 +8,8 @@ import { BiDotsVerticalRounded, } from "react-icons/bi"; -import Dialog from "@/components/dialog"; +import Dialog from "@/components/Dialog"; +import HeaderButton from "@/components/header/HeaderButton"; import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace"; import { SaveFile, @@ -17,41 +18,11 @@ import { import { useInputOutput } from "@/core/workspace/utils/io/useInputOutput"; import { cn } from "@/lib/classnames"; -interface HeaderButtonProps { - onClick?: () => void; - children: React.ReactNode; - tooltip: string; -} - -function HeaderButton({ onClick, children, tooltip }: HeaderButtonProps) { - return ( -
- - - {/* Tooltip Container */} -
-
-
- {tooltip} -
-
-
- ); -} - export default function ButtonTray() { const [isOpen, setIsOpen] = useState(false); - const [activeDialog, setActiveDialog] = useState<"reset" | "import" | null>( - null, - ); + const [activeDialog, setActiveDialog] = useState< + "reset" | "import" | "error" | null + >(null); const [pendingFileData, setPendingFileData] = useState(null); const { resetWorkspace } = useCourseWorkspace(); @@ -74,15 +45,16 @@ export default function ButtonTray() { const result = SaveFileSchema.safeParse(json); if (!result.success) { - // You could use another dialog for errors, but for now: - alert("Invalid file format."); + console.error("File validation failed:", result.error); + setActiveDialog("error"); return; } setPendingFileData(result.data); setActiveDialog("import"); } catch { - alert("Failed to parse file."); + console.error("Failed to read or parse file."); + setActiveDialog("error"); } }; reader.readAsText(file); @@ -223,6 +195,27 @@ export default function ButtonTray() {
+ + {/* ERROR DIALOG */} + !open && setActiveDialog(null)} + description="There was an error importing your file. Please make sure it is a valid CARPI file and try again." + onConfirm={() => { + setActiveDialog(null); + return true; + }} + > +
+ +
+
); } diff --git a/src/components/header/HeaderButton.tsx b/src/components/header/HeaderButton.tsx new file mode 100644 index 0000000..5eb749f --- /dev/null +++ b/src/components/header/HeaderButton.tsx @@ -0,0 +1,35 @@ +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} +
+
+
+ ); +} From 858be808ef338903d9ec1d07f410181fc0332e0a Mon Sep 17 00:00:00 2001 From: Miranda Zheng <123515762+mirmirmirr@users.noreply.github.com> Date: Mon, 6 Apr 2026 19:48:17 -0400 Subject: [PATCH 10/11] add media prefers and aria --- src/components/header/HeaderButton.tsx | 1 + src/index.css | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/components/header/HeaderButton.tsx b/src/components/header/HeaderButton.tsx index 5eb749f..599a698 100644 --- a/src/components/header/HeaderButton.tsx +++ b/src/components/header/HeaderButton.tsx @@ -14,6 +14,7 @@ export default function HeaderButton({ return (