diff --git a/src/app/course/[courseName]/calendar/day/Day.tsx b/src/app/course/[courseName]/calendar/day/Day.tsx index 5d5f0ec..301a1aa 100644 --- a/src/app/course/[courseName]/calendar/day/Day.tsx +++ b/src/app/course/[courseName]/calendar/day/Day.tsx @@ -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 }) => ( - ); - } else if (type === "page" && "text" in item) { - return ; - } else if (type === "quiz" && "questions" in item) { - const quiz = item as { questions: { text: string }[] }; - return quiz.questions.map((q, i: number) => ( -
- -
- )); - } - 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 ( -
- { - 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} - - - {status === "published" ? ( - getPreviewContent(type, item) && ( - {getPreviewContent(type, item)}
- } - targetRef={targetRef} - visible={visible} - /> - ) - ) : ( - - )} - {contextMenuPos && type === "assignment" && ( - - )} - - - ); -} diff --git a/src/app/course/[courseName]/calendar/day/itemInDay/DayItemContextMenu.tsx b/src/app/course/[courseName]/calendar/day/itemInDay/DayItemContextMenu.tsx new file mode 100644 index 0000000..4738092 --- /dev/null +++ b/src/app/course/[courseName]/calendar/day/itemInDay/DayItemContextMenu.tsx @@ -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(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 ( +
+ {confirmingDelete ? ( + <> + + + + + ) : ( + <> + {canvasUrl && ( + <> + + View in Canvas + + + + + )} + {!canvasUrl && ( + + )} + + + )} +
+ ); +}; diff --git a/src/app/course/[courseName]/calendar/day/itemInDay/GetPreviewContent.tsx b/src/app/course/[courseName]/calendar/day/itemInDay/GetPreviewContent.tsx new file mode 100644 index 0000000..117760c --- /dev/null +++ b/src/app/course/[courseName]/calendar/day/itemInDay/GetPreviewContent.tsx @@ -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 ( + + ); + } else if (type === "page" && "text" in item) { + return ; + } else if (type === "quiz" && "questions" in item) { + const quiz = item as { questions: { text: string }[] }; + return quiz.questions.map((q, i: number) => ( +
+ +
+ )); + } + return null; +}; diff --git a/src/app/course/[courseName]/calendar/day/itemInDay/ItemInDay.tsx b/src/app/course/[courseName]/calendar/day/itemInDay/ItemInDay.tsx new file mode 100644 index 0000000..53ab9bd --- /dev/null +++ b/src/app/course/[courseName]/calendar/day/itemInDay/ItemInDay.tsx @@ -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 ( +
+ { + 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} + + + {status === "published" ? ( + + +
+ } + targetRef={targetRef} + visible={visible} + /> + ) : ( + + )} + {contextMenuPos && type === "assignment" && ( + + )} + + + ); +}; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx deleted file mode 100644 index e7c540b..0000000 --- a/src/components/ContextMenu.tsx +++ /dev/null @@ -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(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 ( -
- {items.map((item, i) => - item.href ? ( - - {item.label} - - ) : ( - - ) - )} -
- ); -}