improving assignment context menu

This commit is contained in:
2026-02-20 09:06:52 -07:00
parent b62dcb9c62
commit 865e86d11f
6 changed files with 351 additions and 342 deletions

View File

@@ -5,7 +5,7 @@ import {
} from "@/features/local/utils/timeUtils";
import { useDraggingContext } from "../../context/drag/draggingContext";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { ItemInDay } from "./ItemInDay";
import { ItemInDay } from "./itemInDay/ItemInDay";
import { useTodaysItems } from "./useTodaysItems";
import { DayTitle } from "./DayTitle";
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
@@ -13,7 +13,7 @@ import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
export default function Day({ day, month }: { day: string; month: number }) {
const dayAsDate = getDateFromStringOrThrow(
day,
"calculating same month in day"
"calculating same month in day",
);
const isToday =
getDateOnlyMarkdownString(new Date()) ===
@@ -31,8 +31,8 @@ export default function Day({ day, month }: { day: string; month: number }) {
(holidaysHappeningToday, holiday) => {
const holidayDates = holiday.days.map((d) =>
getDateOnlyMarkdownString(
getDateFromStringOrThrow(d, "holiday date in day component")
)
getDateFromStringOrThrow(d, "holiday date in day component"),
),
);
const today = getDateOnlyMarkdownString(dayAsDate);
@@ -40,16 +40,16 @@ export default function Day({ day, month }: { day: string; month: number }) {
return [...holidaysHappeningToday, holiday.name];
return holidaysHappeningToday;
},
[] as string[]
[] as string[],
);
const semesterStart = getDateFromStringOrThrow(
settings.startDate,
"comparing start date in day"
"comparing start date in day",
);
const semesterEnd = getDateFromStringOrThrow(
settings.endDate,
"comparing end date in day"
"comparing end date in day",
);
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
@@ -90,7 +90,7 @@ export default function Day({ day, month }: { day: string; month: number }) {
status={status}
message={message}
/>
)
),
)}
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
<ItemInDay

View File

@@ -1,256 +0,0 @@
"use client";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link";
import { ReactNode, useCallback, useState } from "react";
import { useCourseContext } from "../../context/courseContext";
import { useTooltip } from "@/components/useTooltip";
import MarkdownDisplay from "@/components/MarkdownDisplay";
import { DraggableItem } from "../../context/drag/draggingContext";
import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../context/drag/dragStyleContext";
import { Tooltip } from "../../../../../components/Tooltip";
import { ContextMenu, ContextMenuItem } from "@/components/ContextMenu";
import {
useCanvasAssignmentsQuery,
useUpdateAssignmentInCanvasMutation,
useDeleteAssignmentFromCanvasMutation,
} from "@/features/canvas/hooks/canvasAssignmentHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import {
useDeleteAssignmentMutation,
useCreateAssignmentMutation,
} from "@/features/local/assignments/assignmentHooks";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { useCalendarItemsContext } from "../../context/calendarItemsContext";
function getDuplicateName(name: string, existingNames: string[]): string {
const match = name.match(/^(.*)\s+(\d+)$/);
const baseName = match ? match[1] : name;
const startNum = match ? parseInt(match[2]) + 1 : 2;
let num = startNum;
while (existingNames.includes(`${baseName} ${num}`)) {
num++;
}
return `${baseName} ${num}`;
}
function getPreviewContent(
type: "assignment" | "page" | "quiz",
item: IModuleItem
): ReactNode {
if (type === "assignment" && "description" in item) {
const assignment = item as {
description: string;
githubClassroomAssignmentShareLink?: string;
};
return (
<MarkdownDisplay
markdown={assignment.description}
replaceText={[
{
source: "insert_github_classroom_url",
destination: assignment.githubClassroomAssignmentShareLink || "",
},
]}
/>
);
} else if (type === "page" && "text" in item) {
return <MarkdownDisplay markdown={item.text as string} />;
} else if (type === "quiz" && "questions" in item) {
const quiz = item as { questions: { text: string }[] };
return quiz.questions.map((q, i: number) => (
<div key={i} className="">
<MarkdownDisplay markdown={q.text as string} />
</div>
));
}
return null;
}
export function ItemInDay({
type,
moduleName,
status,
item,
message,
}: {
type: "assignment" | "page" | "quiz";
status: "localOnly" | "incomplete" | "published";
moduleName: string;
item: IModuleItem;
message: ReactNode;
}) {
const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext();
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
const [contextMenuPos, setContextMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const { data: settings } = useLocalCourseSettingsQuery();
const updateInCanvas = useUpdateAssignmentInCanvasMutation();
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
const deleteLocal = useDeleteAssignmentMutation();
const createAssignment = useCreateAssignmentMutation();
const calendarItems = useCalendarItemsContext();
const assignmentInCanvas =
type === "assignment"
? canvasAssignments?.find((a) => a.name === item.name)
: undefined;
const handleContextMenu = (e: React.MouseEvent) => {
if (type !== "assignment") return;
e.preventDefault();
e.stopPropagation();
setContextMenuPos({ x: e.clientX, y: e.clientY });
setConfirmingDelete(false);
};
const closeContextMenu = useCallback(() => {
setContextMenuPos(null);
setConfirmingDelete(false);
}, []);
const handleDuplicate = useCallback(() => {
const assignment = item as LocalAssignment;
const existingNames = Object.values(calendarItems).flatMap((modules) =>
(modules[moduleName]?.assignments ?? []).map((a) => a.name)
);
const newName = getDuplicateName(item.name, existingNames);
createAssignment.mutate({
courseName,
moduleName,
assignmentName: newName,
assignment: { ...assignment, name: newName },
});
closeContextMenu();
}, [
item,
calendarItems,
moduleName,
createAssignment,
courseName,
closeContextMenu,
]);
const contextMenuItems: ContextMenuItem[] = confirmingDelete
? [
{ label: "Delete from disk?", disabled: true },
{
label: "Yes, delete",
variant: "danger",
onClick: () => {
deleteLocal.mutate({ courseName, moduleName, assignmentName: item.name });
closeContextMenu();
},
},
{ label: "Cancel", onClick: closeContextMenu },
]
: [
...(assignmentInCanvas
? [
{
label: "View in Canvas",
href: `${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`,
},
{
label: "Update in Canvas",
onClick: () => {
updateInCanvas.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignment: item as LocalAssignment,
});
closeContextMenu();
},
disabled: updateInCanvas.isPending,
},
{
label: "Delete from Canvas",
variant: "danger" as const,
onClick: () => {
deleteFromCanvas.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignmentName: item.name,
});
closeContextMenu();
},
disabled: deleteFromCanvas.isPending,
},
]
: [
{
label: "Delete from Disk",
variant: "danger" as const,
onClick: () => setConfirmingDelete(true),
},
]),
{ label: "Duplicate", onClick: handleDuplicate },
];
return (
<div className={" relative group "}>
<Link
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
shallow={true}
className={
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
" bg-slate-800 " +
" block " +
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
(status === "incomplete" && " border-rose-900 ") +
(status === "published" && " border-green-800 ")
}
role="button"
draggable="true"
onDragStart={(e) => {
const draggableItem: DraggableItem = {
type,
item,
sourceModuleName: moduleName,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem)
);
setIsDragging(true);
}}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onContextMenu={handleContextMenu}
ref={targetRef}
>
{item.name}
</Link>
<ClientOnly>
{status === "published" ? (
getPreviewContent(type, item) && (
<Tooltip
message={
<div className="max-w-md">{getPreviewContent(type, item)}</div>
}
targetRef={targetRef}
visible={visible}
/>
)
) : (
<Tooltip message={message} targetRef={targetRef} visible={visible} />
)}
{contextMenuPos && type === "assignment" && (
<ContextMenu
x={contextMenuPos.x}
y={contextMenuPos.y}
items={contextMenuItems}
onClose={closeContextMenu}
/>
)}
</ClientOnly>
</div>
);
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useRef, useEffect, FC, useState } from "react";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { LocalAssignment } from "@/features/local/assignments/models/localAssignment";
import { useCalendarItemsContext } from "../../../context/calendarItemsContext";
import {
useCreateAssignmentMutation,
useDeleteAssignmentMutation,
} from "@/features/local/assignments/assignmentHooks";
import {
useCanvasAssignmentsQuery,
useUpdateAssignmentInCanvasMutation,
useDeleteAssignmentFromCanvasMutation,
} from "@/features/canvas/hooks/canvasAssignmentHooks";
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
import { baseCanvasUrl } from "@/features/canvas/services/canvasServiceUtils";
import { useCourseContext } from "../../../context/courseContext";
function getDuplicateName(name: string, existingNames: string[]): string {
const match = name.match(/^(.*)\s+(\d+)$/);
const baseName = match ? match[1] : name;
const startNum = match ? parseInt(match[2]) + 1 : 2;
let num = startNum;
while (existingNames.includes(`${baseName} ${num}`)) {
num++;
}
return `${baseName} ${num}`;
}
export const DayItemContextMenu: FC<{
x: number;
y: number;
onClose: () => void;
item: IModuleItem;
moduleName: string;
}> = ({ x, y, onClose, item, moduleName }) => {
const { courseName } = useCourseContext();
const ref = useRef<HTMLDivElement>(null);
const calendarItems = useCalendarItemsContext();
const createAssignment = useCreateAssignmentMutation();
const deleteLocal = useDeleteAssignmentMutation();
const { data: canvasAssignments } = useCanvasAssignmentsQuery();
const { data: settings } = useLocalCourseSettingsQuery();
const updateInCanvas = useUpdateAssignmentInCanvasMutation();
const deleteFromCanvas = useDeleteAssignmentFromCanvasMutation();
const [confirmingDelete, setConfirmingDelete] = useState(false);
const assignmentInCanvas = canvasAssignments?.find(
(a) => a.name === item.name,
);
const canvasUrl = assignmentInCanvas
? `${baseCanvasUrl}/courses/${settings.canvasId}/assignments/${assignmentInCanvas.id}`
: undefined;
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setConfirmingDelete(false);
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmingDelete(false);
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
const handleClose = () => {
setConfirmingDelete(false);
onClose();
};
const handleDuplicate = () => {
const assignment = item as LocalAssignment;
const existingNames = Object.values(calendarItems).flatMap((modules) =>
(modules[moduleName]?.assignments ?? []).map((a) => a.name),
);
const newName = getDuplicateName(item.name, existingNames);
createAssignment.mutate({
courseName,
moduleName,
assignmentName: newName,
assignment: { ...assignment, name: newName },
});
handleClose();
};
const handleDelete = () => {
deleteLocal.mutate({ courseName, moduleName, assignmentName: item.name });
handleClose();
};
const handleUpdateCanvas = () => {
if (assignmentInCanvas) {
updateInCanvas.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignment: item as LocalAssignment,
});
handleClose();
}
};
const handleDeleteFromCanvas = () => {
if (assignmentInCanvas) {
deleteFromCanvas.mutate({
canvasAssignmentId: assignmentInCanvas.id,
assignmentName: item.name,
});
handleClose();
}
};
const baseButtonClasses = "unstyled w-full text-left px-4 py-2";
const normalHoverClasses = "hover:bg-slate-700 disabled:opacity-50";
const dangerClasses =
"bg-rose-900/30 hover:bg-rose-950 disabled:opacity-50 text-rose-50";
return (
<div
ref={ref}
className="
fixed z-50 bg-slate-900 border-2 border-slate-700
rounded shadow-xl overflow-hidden
"
style={{ left: x, top: y }}
>
{confirmingDelete ? (
<>
<button
disabled
className={`${baseButtonClasses} ${normalHoverClasses}`}
>
Delete from disk?
</button>
<button
onClick={handleDelete}
className={`${baseButtonClasses} ${dangerClasses}`}
>
Yes, delete
</button>
<button
onClick={handleClose}
className={`${baseButtonClasses} ${normalHoverClasses}`}
>
Cancel
</button>
</>
) : (
<>
{canvasUrl && (
<>
<a
href={canvasUrl}
target="_blank"
rel="noreferrer"
className="unstyled block px-4 py-2 text-sm hover:bg-slate-700 cursor-pointer"
onClick={handleClose}
>
View in Canvas
</a>
<button
onClick={handleUpdateCanvas}
disabled={updateInCanvas.isPending}
className="w-full text-left px-4 py-2 text-sm hover:bg-slate-700 disabled:opacity-50"
>
Update in Canvas
</button>
<button
onClick={handleDeleteFromCanvas}
disabled={deleteFromCanvas.isPending}
className={`${baseButtonClasses} ${dangerClasses}`}
>
Delete from Canvas
</button>
</>
)}
{!canvasUrl && (
<button
onClick={() => setConfirmingDelete(true)}
className={`${baseButtonClasses} ${dangerClasses}`}
>
Delete from Disk
</button>
)}
<button
onClick={handleDuplicate}
className={`${baseButtonClasses} ${normalHoverClasses}`}
>
Duplicate
</button>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,37 @@
"use client";
import MarkdownDisplay from "@/components/MarkdownDisplay";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { FC } from "react";
export const GetPreviewContent: FC<{
type: "assignment" | "page" | "quiz";
item: IModuleItem;
}> = ({ type, item }) => {
if (type === "assignment" && "description" in item) {
const assignment = item as {
description: string;
githubClassroomAssignmentShareLink?: string;
};
return (
<MarkdownDisplay
markdown={assignment.description}
replaceText={[
{
source: "insert_github_classroom_url",
destination: assignment.githubClassroomAssignmentShareLink || "",
},
]}
/>
);
} else if (type === "page" && "text" in item) {
return <MarkdownDisplay markdown={item.text as string} />;
} else if (type === "quiz" && "questions" in item) {
const quiz = item as { questions: { text: string }[] };
return quiz.questions.map((q, i: number) => (
<div key={i} className="">
<MarkdownDisplay markdown={q.text as string} />
</div>
));
}
return null;
};

View File

@@ -0,0 +1,102 @@
"use client";
import { IModuleItem } from "@/features/local/modules/IModuleItem";
import { getModuleItemUrl } from "@/services/urlUtils";
import Link from "next/link";
import { FC, ReactNode, useCallback, useState } from "react";
import { useCourseContext } from "../../../context/courseContext";
import { useTooltip } from "@/components/useTooltip";
import { DraggableItem } from "../../../context/drag/draggingContext";
import ClientOnly from "@/components/ClientOnly";
import { useDragStyleContext } from "../../../context/drag/dragStyleContext";
import { Tooltip } from "../../../../../../components/Tooltip";
import { DayItemContextMenu } from "./DayItemContextMenu";
import { GetPreviewContent } from "./GetPreviewContent";
export const ItemInDay: FC<{
type: "assignment" | "page" | "quiz";
status: "localOnly" | "incomplete" | "published";
moduleName: string;
item: IModuleItem;
message: ReactNode;
}> = ({ type, moduleName, status, item, message }) => {
const { courseName } = useCourseContext();
const { setIsDragging } = useDragStyleContext();
const { visible, targetRef, showTooltip, hideTooltip } = useTooltip(500);
const [contextMenuPos, setContextMenuPos] = useState<{
x: number;
y: number;
} | null>(null);
const handleContextMenu = (e: React.MouseEvent) => {
if (type !== "assignment") return;
e.preventDefault();
e.stopPropagation();
setContextMenuPos({ x: e.clientX, y: e.clientY });
};
const closeContextMenu = useCallback(() => {
setContextMenuPos(null);
}, []);
return (
<div className={" relative group "}>
<Link
href={getModuleItemUrl(courseName, moduleName, type, item.name)}
shallow={true}
className={
" border rounded-sm sm:px-1 sm:mx-1 break-words mb-1 truncate sm:text-wrap text-nowrap " +
" bg-slate-800 " +
" block " +
(status === "localOnly" && " text-slate-500 border-slate-600 ") +
(status === "incomplete" && " border-rose-900 ") +
(status === "published" && " border-green-800 ")
}
role="button"
draggable="true"
onDragStart={(e) => {
const draggableItem: DraggableItem = {
type,
item,
sourceModuleName: moduleName,
};
e.dataTransfer.setData(
"draggableItem",
JSON.stringify(draggableItem),
);
setIsDragging(true);
}}
onMouseEnter={showTooltip}
onMouseLeave={hideTooltip}
onContextMenu={handleContextMenu}
ref={targetRef}
>
{item.name}
</Link>
<ClientOnly>
{status === "published" ? (
<Tooltip
message={
<div className="max-w-md">
<GetPreviewContent type={type} item={item} />
</div>
}
targetRef={targetRef}
visible={visible}
/>
) : (
<Tooltip message={message} targetRef={targetRef} visible={visible} />
)}
{contextMenuPos && type === "assignment" && (
<DayItemContextMenu
x={contextMenuPos.x}
y={contextMenuPos.y}
onClose={closeContextMenu}
item={item}
moduleName={moduleName}
/>
)}
</ClientOnly>
</div>
);
};

View File

@@ -1,78 +0,0 @@
"use client";
import { useEffect, useRef } from "react";
export interface ContextMenuItem {
label: string;
onClick?: () => void;
href?: string;
variant?: "default" | "danger";
disabled?: boolean;
}
export function ContextMenu({
x,
y,
items,
onClose,
}: {
x: number;
y: number;
items: ContextMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
return (
<div
ref={ref}
className="fixed z-50 bg-slate-800 border border-slate-600 rounded shadow-xl overflow-hidden min-w-44"
style={{ left: x, top: y }}
>
{items.map((item, i) =>
item.href ? (
<a
key={i}
href={item.href}
target="_blank"
rel="noreferrer"
className="block px-4 py-2 text-sm hover:bg-slate-700 cursor-pointer whitespace-nowrap"
onClick={onClose}
>
{item.label}
</a>
) : (
<button
key={i}
onClick={item.onClick}
disabled={item.disabled}
className={
"w-full text-left px-4 py-2 text-sm hover:bg-slate-700 disabled:opacity-50 whitespace-nowrap " +
(item.variant === "danger"
? "text-red-400 hover:text-red-300"
: "")
}
>
{item.label}
</button>
)
)}
</div>
);
}