Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 54 additions & 88 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
90 changes: 90 additions & 0 deletions src/components/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React, { useState, useCallback } from "react";

import * as RadixDialog from "@radix-ui/react-dialog";

import { cn } from "@/lib/classnames";

type DialogProps = {
title: string;
description: React.ReactNode;
children: React.ReactNode;
trigger?: React.ReactNode;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};

export default function Dialog({
title,
description,
children,
trigger,
disabled = 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],
);

return (
<RadixDialog.Root open={open} onOpenChange={handleOpenChange}>
{trigger && (
<RadixDialog.Trigger
asChild
disabled={disabled}
onClick={(e) => {
if (disabled) {
e.preventDefault();
e.stopPropagation();
}
}}
aria-disabled={disabled}
>
{trigger}
</RadixDialog.Trigger>
)}

<RadixDialog.Portal>
<RadixDialog.Overlay
className={cn(
"dialog-overlay fixed inset-0 z-100 bg-darkblue/50 transition-opacity",
)}
/>
<RadixDialog.Content asChild>
<div
className={cn(
"dialog-content fixed inset-0 z-100 m-auto flex flex-col overflow-hidden",
Comment thread
mirmirmirr marked this conversation as resolved.
"bg-carpipink rounded-3xl p-6 shadow-md focus:outline-none border border-darkblue",
"h-fit w-3/4 md:w-fit md:max-w-lg md:min-w-sm",
Comment thread
mirmirmirr marked this conversation as resolved.
)}
>
<RadixDialog.Title asChild>
<div className="flex flex-col items-center gap-4">
<p className="text-lg font-bold">{title}</p>
</div>
</RadixDialog.Title>

<RadixDialog.Description asChild>
<div className="mt-2 w-full text-center">{description}</div>
</RadixDialog.Description>

{children}
</div>
</RadixDialog.Content>
</RadixDialog.Portal>
</RadixDialog.Root>
);
}
175 changes: 138 additions & 37 deletions src/components/header/ButtonTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,25 @@ import {
BiDotsVerticalRounded,
} from "react-icons/bi";

import Dialog from "@/components/Dialog";
Comment thread
mirmirmirr marked this conversation as resolved.
import HeaderButton from "@/components/header/HeaderButton";
import { useCourseWorkspace } from "@/core/workspace/useCourseWorkspace";
import {
SaveFile,
SaveFileSchema,
} from "@/core/workspace/utils/io/inputOutput";
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 (
<div className="relative group flex flex-col items-center">
<button
onClick={onClick}
className={cn(
"p-3 rounded-full w-fit aspect-square flex items-center justify-center hover:cursor-pointer",
"bg-darkblue/20 hover:bg-darkblue hover:text-carpipink transition-colors",
)}
>
{children}
</button>

{/* Tooltip Container */}
<div className="absolute -bottom-7 z-50 hidden group-hover:flex flex-col items-center">
<div className="w-2 h-2 bg-darkblue rotate-45"></div>
<div className="bg-darkblue text-carpipink text-tiny py-0.5 px-2 -mt-1 rounded-full whitespace-nowrap">
{tooltip}
</div>
</div>
</div>
);
}

export default function ButtonTray() {
const [isOpen, setIsOpen] = useState(false);
const [activeDialog, setActiveDialog] = useState<
"reset" | "import" | "error" | null
>(null);
const [pendingFileData, setPendingFileData] = useState<SaveFile | null>(null);

const { resetWorkspace } = useCourseWorkspace();
const { exportPlan, importPlan } = useInputOutput();

const fileInputRef = useRef<HTMLInputElement>(null);

const handleImportClick = () => {
Expand All @@ -57,21 +36,75 @@ export default function ButtonTray() {

const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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) {
console.error("File validation failed:", result.error);
setActiveDialog("error");
return;
}

setPendingFileData(result.data);
setActiveDialog("import");
} catch {
console.error("Failed to read or parse file.");
setActiveDialog("error");
}
};
reader.readAsText(file);
e.target.value = ""; // Reset input
};

const confirmReset = () => {
resetWorkspace();
setActiveDialog(null);
return true;
};

const confirmImport = () => {
if (pendingFileData) {
importPlan(pendingFileData);
}
e.target.value = "";
setPendingFileData(null);
setActiveDialog(null);
return true;
};

const buttonList = (
<>
<HeaderButton onClick={resetWorkspace} tooltip="Reset Workspace">
<HeaderButton
onClick={() => {
setActiveDialog("reset");
setIsOpen(false);
}}
tooltip="Reset Workspace"
>
<BiReset className="w-5 h-5" />
</HeaderButton>
<HeaderButton onClick={exportPlan} tooltip="Export Plan">

<HeaderButton
onClick={() => {
exportPlan();
setIsOpen(false);
}}
tooltip="Export Plan"
>
<BiExport className="w-5 h-5" />
</HeaderButton>
<HeaderButton onClick={handleImportClick} tooltip="Import Plan">

<HeaderButton
onClick={() => {
handleImportClick();
setIsOpen(false);
}}
tooltip="Import Plan"
>
<BiImport className="w-5 h-5" />
</HeaderButton>
</>
Expand Down Expand Up @@ -115,6 +148,74 @@ export default function ButtonTray() {
</AnimatePresence>
</div>
</div>

<Dialog
title="Resetting Workspace"
open={activeDialog === "reset"}
onOpenChange={(open) => !open && setActiveDialog(null)}
description="Are you sure you want to reset your entire workspace? This will delete all semesters and courses."
onConfirm={confirmReset}
>
<div className="flex gap-4 mt-6 justify-center">
<button
onClick={() => setActiveDialog(null)}
className="px-4 py-2 bg-darkblue/20 hover:text-carpipink rounded-xl hover:bg-darkblue hover:cursor-pointer"
>
Cancel
</button>
<button
onClick={confirmReset}
className="px-4 py-2 bg-rosewood text-carpipink rounded-xl hover:cursor-pointer hover:bg-[color-mix(in_oklab,var(--color-rosewood)_90%,black_10%)]"
>
Confirm Reset
</button>
</div>
</Dialog>

{/* IMPORT DIALOG */}
<Dialog
title="Importing New Plan!"
open={activeDialog === "import"}
onOpenChange={(open) => !open && setActiveDialog(null)}
description="Importing will overwrite your current plan. This action cannot be undone."
onConfirm={confirmImport}
>
<div className="flex gap-4 mt-6 justify-center">
<button
onClick={() => setActiveDialog(null)}
className="px-4 py-2 bg-darkblue/20 hover:text-carpipink rounded-xl hover:bg-darkblue hover:cursor-pointer"
>
Cancel
</button>
<button
onClick={confirmImport}
className="px-4 py-2 bg-slategray text-carpipink rounded-xl hover:cursor-pointer hover:bg-[color-mix(in_oklab,var(--color-slategray)_90%,black_10%)]"
>
Overwrite & Import
</button>
</div>
</Dialog>

{/* ERROR DIALOG */}
<Dialog
title="Error Importing File"
open={activeDialog === "error"}
onOpenChange={(open) => !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;
}}
>
<div className="flex gap-4 mt-6 justify-center">
<button
onClick={() => setActiveDialog(null)}
className="px-4 py-2 bg-darkblue/20 hover:text-carpipink rounded-xl hover:bg-darkblue hover:cursor-pointer"
>
Close
</button>
</div>
</Dialog>
</>
);
}
36 changes: 36 additions & 0 deletions src/components/header/HeaderButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { cn } from "@/lib/classnames";

interface HeaderButtonProps {
onClick?: () => void;
children: React.ReactNode;
tooltip: string;
}

export default function HeaderButton({
onClick,
children,
tooltip,
}: HeaderButtonProps) {
return (
<div className="relative group flex flex-col items-center">
<button
aria-label="CARPI Options"
onClick={onClick}
className={cn(
"p-3 rounded-full w-fit aspect-square flex items-center justify-center hover:cursor-pointer",
"bg-darkblue/20 hover:bg-darkblue hover:text-carpipink transition-colors",
)}
>
{children}
Comment thread
mirmirmirr marked this conversation as resolved.
</button>

{/* Tooltip Container */}
<div className="absolute -bottom-7 z-50 hidden group-hover:flex flex-col items-center">
<div className="w-2 h-2 bg-darkblue rotate-45"></div>
<div className="bg-darkblue text-carpipink text-tiny py-0.5 px-2 -mt-1 rounded-full whitespace-nowrap">
{tooltip}
</div>
</div>
</div>
);
}
6 changes: 0 additions & 6 deletions src/core/workspace/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand Down
Loading
Loading