mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-27 07:58:31 -06:00
improving assignment context menu
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
102
src/app/course/[courseName]/calendar/day/itemInDay/ItemInDay.tsx
Normal file
102
src/app/course/[courseName]/calendar/day/itemInDay/ItemInDay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user