mirror of
https://github.com/alexmickelson/canvasManagement.git
synced 2026-03-25 23:28:33 -06:00
improving assignment context menu
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
|||||||
} from "@/features/local/utils/timeUtils";
|
} from "@/features/local/utils/timeUtils";
|
||||||
import { useDraggingContext } from "../../context/drag/draggingContext";
|
import { useDraggingContext } from "../../context/drag/draggingContext";
|
||||||
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
import { useLocalCourseSettingsQuery } from "@/features/local/course/localCoursesHooks";
|
||||||
import { ItemInDay } from "./ItemInDay";
|
import { ItemInDay } from "./itemInDay/ItemInDay";
|
||||||
import { useTodaysItems } from "./useTodaysItems";
|
import { useTodaysItems } from "./useTodaysItems";
|
||||||
import { DayTitle } from "./DayTitle";
|
import { DayTitle } from "./DayTitle";
|
||||||
import { getDayOfWeek } from "@/features/local/course/localCourseSettings";
|
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 }) {
|
export default function Day({ day, month }: { day: string; month: number }) {
|
||||||
const dayAsDate = getDateFromStringOrThrow(
|
const dayAsDate = getDateFromStringOrThrow(
|
||||||
day,
|
day,
|
||||||
"calculating same month in day"
|
"calculating same month in day",
|
||||||
);
|
);
|
||||||
const isToday =
|
const isToday =
|
||||||
getDateOnlyMarkdownString(new Date()) ===
|
getDateOnlyMarkdownString(new Date()) ===
|
||||||
@@ -31,8 +31,8 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
(holidaysHappeningToday, holiday) => {
|
(holidaysHappeningToday, holiday) => {
|
||||||
const holidayDates = holiday.days.map((d) =>
|
const holidayDates = holiday.days.map((d) =>
|
||||||
getDateOnlyMarkdownString(
|
getDateOnlyMarkdownString(
|
||||||
getDateFromStringOrThrow(d, "holiday date in day component")
|
getDateFromStringOrThrow(d, "holiday date in day component"),
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
const today = getDateOnlyMarkdownString(dayAsDate);
|
const today = getDateOnlyMarkdownString(dayAsDate);
|
||||||
|
|
||||||
@@ -40,16 +40,16 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
return [...holidaysHappeningToday, holiday.name];
|
return [...holidaysHappeningToday, holiday.name];
|
||||||
return holidaysHappeningToday;
|
return holidaysHappeningToday;
|
||||||
},
|
},
|
||||||
[] as string[]
|
[] as string[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const semesterStart = getDateFromStringOrThrow(
|
const semesterStart = getDateFromStringOrThrow(
|
||||||
settings.startDate,
|
settings.startDate,
|
||||||
"comparing start date in day"
|
"comparing start date in day",
|
||||||
);
|
);
|
||||||
const semesterEnd = getDateFromStringOrThrow(
|
const semesterEnd = getDateFromStringOrThrow(
|
||||||
settings.endDate,
|
settings.endDate,
|
||||||
"comparing end date in day"
|
"comparing end date in day",
|
||||||
);
|
);
|
||||||
|
|
||||||
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
|
const isInSemester = semesterStart < dayAsDate && semesterEnd > dayAsDate;
|
||||||
@@ -90,7 +90,7 @@ export default function Day({ day, month }: { day: string; month: number }) {
|
|||||||
status={status}
|
status={status}
|
||||||
message={message}
|
message={message}
|
||||||
/>
|
/>
|
||||||
)
|
),
|
||||||
)}
|
)}
|
||||||
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
|
{todaysQuizzes.map(({ quiz, moduleName, status, message }) => (
|
||||||
<ItemInDay
|
<ItemInDay
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user