diff --git a/AGENTS.md b/AGENTS.md index 686512709..8c682be75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ - Use **Cursors** (`.cursor()`) to stream data from MongoDB, ensuring the script remains memory-efficient regardless of dataset size. - Use **Batching** with `bulkWrite` (e.g., batches of 500) to maximize performance and minimize network roundtrips. - Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable. +- When making changes to the structure of the Course, consider how it affects its representation on its public page (`apps/web/app/(with-contexts)/(with-layout)/p/[id]/page.tsx`) and the course viewer (`apps/web/app/(with-contexts)/course/[slug]/[id]/page.tsx`). ### Workspace map (core modules): @@ -48,6 +49,7 @@ - Always add or update test when introducing changes to `apps/web/graphql` folder, even if nobody asked. - Run `pnpm test` to run the tests. - Fix any test or type errors until the whole suite is green. +- Refrain from creating new files when adding tests in `apps/web/graphql` subdirectories. Re-use `logic.test.ts` files for adding new test suites i.e. describe blocks. ## PR instructions diff --git a/apps/docs/public/assets/products/lesson-reordering.png b/apps/docs/public/assets/products/lesson-reordering.png new file mode 100644 index 000000000..8756ed7f9 Binary files /dev/null and b/apps/docs/public/assets/products/lesson-reordering.png differ diff --git a/apps/docs/public/assets/products/section-reordering.png b/apps/docs/public/assets/products/section-reordering.png new file mode 100644 index 000000000..b6fbfa7e2 Binary files /dev/null and b/apps/docs/public/assets/products/section-reordering.png differ diff --git a/apps/docs/src/pages/en/courses/add-content.md b/apps/docs/src/pages/en/courses/add-content.md index c1a4b7671..9951c6eae 100644 --- a/apps/docs/src/pages/en/courses/add-content.md +++ b/apps/docs/src/pages/en/courses/add-content.md @@ -6,7 +6,7 @@ layout: ../../../layouts/MainLayout.astro CourseLit uses the concept of a `Lesson`. It is very similar to what we generally see in books, i.e., a large piece of information is divided into smaller chunks called lessons. -Similarly, you can break down your course into `Lessons` and group the lessons into [Sections](/en/products/section). +Similarly, you can break down your course into `Lessons` and group the lessons into [Sections](/en/courses/section). ## Sections diff --git a/apps/docs/src/pages/en/courses/section.md b/apps/docs/src/pages/en/courses/section.md index d6d03ed1e..0061285a1 100644 --- a/apps/docs/src/pages/en/courses/section.md +++ b/apps/docs/src/pages/en/courses/section.md @@ -46,6 +46,18 @@ Here’s how sections look in various parts of the platform. ![Edit Section Settings](/assets/products/edit-section-settings.png) +## Rearranging Sections + +You can move sections up or down as you like. Click the chevron up or down buttons to move a section. + +![Move a section](/assets/products/section-reordering.png) + +## Moving a Lesson Between Sections + +Use the drag-and-drop handles on the left side of a lesson's listing to move it to any section. + +![Move a lesson](/assets/products/lesson-reordering.png) + ## Drip a Section You can release a section on a **specific date** or **after a certain number of days have elapsed since the time a student enrolls**. @@ -76,6 +88,8 @@ If drip configuration is enabled for a section, a student won't be able to acces 4. Select the number of days. 5. Click `Continue` to save it. +> Rearranging a section with drip enabled may affect its drip schedule; use caution. + ### Notify Users When a Section Has Dripped 1. Click on the `Email Notification` checkbox. @@ -98,7 +112,7 @@ On the course viewer, the customer will see the clock icon against the section n 2. Click `Delete` on the confirmation dialog. -> A section must be empty (i.e., have no lessons attached to it) in order to be deleted. +> A section must be empty (i.e., have no lessons attached) before it can be deleted. Move any lessons to another section to make it empty. ## Next Step diff --git a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts index ea139709d..0e298e6b2 100644 --- a/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course-old/[slug]/[id]/helpers.ts @@ -1,4 +1,3 @@ -import { sortCourseGroups } from "@ui-lib/utils"; import { Course, Group, Lesson } from "@courselit/common-models"; import { FetchBuilder } from "@courselit/utils"; @@ -92,15 +91,16 @@ export const getProduct = async ( export function formatCourse( post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, ): CourseFrontend { - for (const group of sortCourseGroups(post as Course)) { - (group as GroupWithLessons).lessons = post.lessons + const groupsWithLessons = post.groups.map((group) => ({ + ...group, + lessons: post.lessons .filter((lesson: Lesson) => lesson.groupId === group.id) .sort( (a: any, b: any) => group.lessonsOrder?.indexOf(a.lessonId) - group.lessonsOrder?.indexOf(b.lessonId), - ); - } + ), + })); return { title: post.title, @@ -111,7 +111,7 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, - groups: post.groups as GroupWithLessons[], + groups: groupsWithLessons as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, paymentPlans: post.paymentPlans, diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts new file mode 100644 index 000000000..a3aa6fb53 --- /dev/null +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/helpers.test.ts @@ -0,0 +1,67 @@ +import { formatCourse } from "../helpers"; + +describe("course helpers formatCourse", () => { + const makeCourse = () => + ({ + title: "Course", + description: "{}", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator", + slug: "course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-1", + groups: [ + { + id: "group-2", + name: "Group 2", + rank: 2000, + lessonsOrder: ["lesson-3", "lesson-2"], + }, + { + id: "group-1", + name: "Group 1", + rank: 1000, + lessonsOrder: ["lesson-1"], + }, + ], + lessons: [ + { + lessonId: "lesson-2", + title: "Lesson 2", + groupId: "group-2", + }, + { + lessonId: "lesson-1", + title: "Lesson 1", + groupId: "group-1", + }, + { + lessonId: "lesson-3", + title: "Lesson 3", + groupId: "group-2", + }, + ], + }) as any; + + it("preserves group order from the backend response", () => { + const formatted = formatCourse(makeCourse()); + + expect(formatted.groups.map((group) => group.id)).toEqual([ + "group-2", + "group-1", + ]); + }); + + it("sorts lessons within each group by lessonsOrder", () => { + const formatted = formatCourse(makeCourse()); + + expect( + formatted.groups[0].lessons.map((lesson) => lesson.lessonId), + ).toEqual(["lesson-3", "lesson-2"]); + }); +}); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx index 31f698d49..1e5d76831 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/__tests__/layout-with-sidebar.test.tsx @@ -214,7 +214,7 @@ describe("generateSideBarItems", () => { status: true, type: Constants.dripType[1].split("-")[0].toUpperCase(), dateInUTC: new Date( - "2026-03-24T00:00:00.000Z", + "2099-03-24T00:00:00.000Z", ).getTime(), }, }, @@ -374,7 +374,7 @@ describe("generateSideBarItems", () => { status: true, type: Constants.dripType[1].split("-")[0].toUpperCase(), dateInUTC: new Date( - "2026-03-24T00:00:00.000Z", + "2099-03-24T00:00:00.000Z", ).getTime(), }, }, @@ -458,7 +458,7 @@ describe("generateSideBarItems", () => { ); expect(items[2].badge?.text).toBe("Mar 22, 2026"); - expect(items[2].badge?.description).toBe("Available on Mar 22, 2026"); + expect(items[2].badge?.description).toBe(""); }); it("uses purchase createdAt as the relative drip anchor when lastDripAt is absent", () => { @@ -648,4 +648,81 @@ describe("generateSideBarItems", () => { expect(items[3].badge?.text).toBe("3 days"); }); + + it("renders reordered lessons under the destination section in sidebar order", () => { + const course = { + title: "Course", + description: "", + featuredImage: undefined, + updatedAt: new Date().toISOString(), + creatorId: "creator-1", + slug: "test-course", + cost: 0, + courseId: "course-1", + tags: [], + paymentPlans: [], + defaultPaymentPlan: "", + firstLesson: "lesson-2", + groups: [ + { + id: "group-1", + name: "First Section", + lessons: [ + { + lessonId: "lesson-1", + title: "Text 1", + requiresEnrollment: false, + }, + ], + }, + { + id: "group-2", + name: "Second Section", + lessons: [ + { + lessonId: "lesson-2", + title: "Chapter 5 - Text 2", + requiresEnrollment: false, + }, + { + lessonId: "lesson-3", + title: "Text 3", + requiresEnrollment: false, + }, + ], + }, + ], + } as unknown as CourseFrontend; + + const profile = { + userId: "user-1", + purchases: [ + { + courseId: "course-1", + accessibleGroups: ["group-1", "group-2"], + }, + ], + } as unknown as Profile; + + const items = generateSideBarItems( + course, + profile, + "/course/test-course/course-1", + ); + + const firstSectionItems = items.find( + (item) => item.title === "First Section", + )?.items; + const secondSectionItems = items.find( + (item) => item.title === "Second Section", + )?.items; + + expect(firstSectionItems?.map((item) => item.title)).toEqual([ + "Text 1", + ]); + expect(secondSectionItems?.map((item) => item.title)).toEqual([ + "Chapter 5 - Text 2", + "Text 3", + ]); + }); }); diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts index ea139709d..0e298e6b2 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/helpers.ts @@ -1,4 +1,3 @@ -import { sortCourseGroups } from "@ui-lib/utils"; import { Course, Group, Lesson } from "@courselit/common-models"; import { FetchBuilder } from "@courselit/utils"; @@ -92,15 +91,16 @@ export const getProduct = async ( export function formatCourse( post: Course & { lessons: Lesson[]; firstLesson: string; groups: Group[] }, ): CourseFrontend { - for (const group of sortCourseGroups(post as Course)) { - (group as GroupWithLessons).lessons = post.lessons + const groupsWithLessons = post.groups.map((group) => ({ + ...group, + lessons: post.lessons .filter((lesson: Lesson) => lesson.groupId === group.id) .sort( (a: any, b: any) => group.lessonsOrder?.indexOf(a.lessonId) - group.lessonsOrder?.indexOf(b.lessonId), - ); - } + ), + })); return { title: post.title, @@ -111,7 +111,7 @@ export function formatCourse( slug: post.slug, cost: post.cost, courseId: post.courseId, - groups: post.groups as GroupWithLessons[], + groups: groupsWithLessons as GroupWithLessons[], tags: post.tags, firstLesson: post.firstLesson, paymentPlans: post.paymentPlans, diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx new file mode 100644 index 000000000..0ebfe1abe --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/content-sections-board.test.tsx @@ -0,0 +1,276 @@ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import ContentSectionsBoard from "../content-sections-board"; + +const toastMock = jest.fn(); +const reorderExecMock = jest.fn(); +const moveLessonExecMock = jest.fn(); + +let resolveMoveLesson: (() => void) | null = null; +let rejectMoveLesson: ((error: unknown) => void) | null = null; + +jest.mock("@courselit/components-library", () => ({ + useToast: () => ({ toast: toastMock }), +})); + +jest.mock("../multi-container-drag-and-drop", () => ({ + MultiContainerDragAndDrop: ({ + children, + onMove, + onDragStateChange, + }: any) => ( +
+ + + {children} +
+ ), +})); + +jest.mock("@courselit/utils", () => ({ + FetchBuilder: class { + payload: any; + setUrl() { + return this; + } + setPayload(payload: any) { + this.payload = payload; + return this; + } + setIsGraphQLEndpoint() { + return this; + } + build() { + const query = this.payload?.query ?? ""; + if (query.includes("moveLesson")) { + return { + exec: moveLessonExecMock, + }; + } + + return { + exec: reorderExecMock, + }; + } + }, +})); + +jest.mock("../content-section-card", () => { + return function MockContentSectionCard(props: any) { + return ( +
+
+ {(props.lessons ?? []) + .map((lesson: any) => lesson.title) + .join(",")} +
+ + +
+ ); + }; +}); + +describe("ContentSectionsBoard", () => { + beforeEach(() => { + toastMock.mockReset(); + reorderExecMock.mockReset().mockResolvedValue({}); + moveLessonExecMock.mockReset().mockImplementation( + () => + new Promise((resolve, reject) => { + resolveMoveLesson = () => resolve({}); + rejectMoveLesson = reject; + }), + ); + }); + + const sections = [ + { + id: "group-1", + name: "Group 1", + rank: 1000, + collapsed: false, + lessonsOrder: ["lesson-1"], + }, + { + id: "group-2", + name: "Group 2", + rank: 2000, + collapsed: false, + lessonsOrder: [], + }, + ] as any; + + const lessons = [ + { + lessonId: "lesson-1", + title: "Lesson 1", + type: "text", + groupId: "group-1", + published: true, + }, + ] as any; + + it("disables section move controls while moveLesson is in-flight", async () => { + const setOrderedSections = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await waitFor(() => + expect(screen.getByTestId("move-down-group-1")).toBeDisabled(), + ); + + fireEvent.click(screen.getByTestId("move-down-group-1")); + expect(reorderExecMock).not.toHaveBeenCalled(); + + await act(async () => { + resolveMoveLesson?.(); + }); + + await waitFor(() => + expect(screen.getByTestId("move-down-group-1")).not.toBeDisabled(), + ); + }); + + it("moves section down using reorderGroups mutation", async () => { + const setOrderedSections = jest.fn(); + + render( + , + ); + + fireEvent.click(screen.getByTestId("move-down-group-1")); + + expect(setOrderedSections).toHaveBeenCalledTimes(1); + expect(reorderExecMock).toHaveBeenCalledTimes(1); + }); + + it("rolls back optimistic lesson move when moveLesson fails", async () => { + render( + , + ); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await act(async () => { + rejectMoveLesson?.(new Error("Move failed")); + }); + + await waitFor(() => + expect(toastMock).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "destructive", + }), + ), + ); + }); + + it("keeps moved lessons in destination section after section reorder", async () => { + moveLessonExecMock.mockResolvedValueOnce({}); + + const Harness = () => { + const [localSections, setLocalSections] = React.useState(sections); + return ( + + ); + }; + + render(); + + fireEvent.click(screen.getByTestId("start-lesson-drag")); + fireEvent.click(screen.getByTestId("end-lesson-drag")); + + await waitFor(() => + expect(screen.getByTestId("lessons-group-2")).toHaveTextContent( + "Lesson 1", + ), + ); + + fireEvent.click(screen.getByTestId("move-up-group-2")); + + await waitFor(() => + expect(screen.getByTestId("lessons-group-2")).toHaveTextContent( + "Lesson 1", + ), + ); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts new file mode 100644 index 000000000..e38eaa2f3 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/helpers.test.ts @@ -0,0 +1,48 @@ +import { applyLessonMove, buildLessonMap } from "../helpers"; + +describe("content lesson helpers", () => { + it("builds lesson map by section order", () => { + const sections: any[] = [ + { + id: "group-1", + lessonsOrder: ["lesson-2", "lesson-1"], + }, + ]; + const lessons: any[] = [ + { lessonId: "lesson-1", groupId: "group-1", title: "L1" }, + { lessonId: "lesson-2", groupId: "group-1", title: "L2" }, + ]; + + const map = buildLessonMap(sections as any, lessons as any); + expect(map["group-1"].map((lesson) => lesson.lessonId)).toEqual([ + "lesson-2", + "lesson-1", + ]); + }); + + it("moves lessons across sections optimistically", () => { + const current: any = { + "group-1": [ + { lessonId: "lesson-1", groupId: "group-1", title: "L1" }, + { lessonId: "lesson-2", groupId: "group-1", title: "L2" }, + ], + "group-2": [], + }; + + const next = applyLessonMove({ + current, + lessonId: "lesson-2", + sourceSectionId: "group-1", + destinationSectionId: "group-2", + destinationIndex: 0, + }); + + expect(next["group-1"].map((lesson: any) => lesson.lessonId)).toEqual([ + "lesson-1", + ]); + expect(next["group-2"].map((lesson: any) => lesson.lessonId)).toEqual([ + "lesson-2", + ]); + expect(next["group-2"][0].groupId).toBe("group-2"); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx new file mode 100644 index 000000000..f3a1c9813 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/__tests__/lesson-list-item.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import LessonListItem from "../lesson-list-item"; +import type { useMultiContainerSortableItem } from "../multi-container-drag-and-drop"; + +jest.mock("next/navigation", () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})); + +describe("LessonListItem", () => { + it("disables lesson drag handle when disabled is true", () => { + render( + ["attributes"] + } + listeners={ + {} as ReturnType< + typeof useMultiContainerSortableItem + >["listeners"] + } + setNodeRef={ + jest.fn() as ReturnType< + typeof useMultiContainerSortableItem + >["setNodeRef"] + } + style={{}} + />, + ); + + expect(screen.getByTestId("lesson-drag-handle")).toBeDisabled(); + }); + + it("keeps lesson drag handle enabled when disabled is false", () => { + render( + ["attributes"] + } + listeners={ + {} as ReturnType< + typeof useMultiContainerSortableItem + >["listeners"] + } + setNodeRef={ + jest.fn() as ReturnType< + typeof useMultiContainerSortableItem + >["setNodeRef"] + } + style={{}} + />, + ); + + expect(screen.getByTestId("lesson-drag-handle")).not.toBeDisabled(); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx new file mode 100644 index 000000000..ac1a776dc --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-section-card.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + ChevronUp, + ChevronDown, + ChevronRight, + Droplets, + MoreHorizontal, + Plus, +} from "lucide-react"; +import Link from "next/link"; +import { Constants, Group } from "@courselit/common-models"; +import { + BUTTON_NEW_LESSON_TEXT, + BUTTON_NEW_LESSON_TEXT_DOWNLOAD, + EDIT_SECTION_HEADER, + BUTTON_MOVE_SECTION_UP, + BUTTON_MOVE_SECTION_DOWN, +} from "@ui-config/strings"; +import SectionLessonList from "./section-lesson-list"; +import { LessonSummary } from "./helpers"; + +export default function ContentSectionCard({ + section, + lessons, + lessonInsertionCueIndex, + collapsed, + sectionMenuOpenId, + productType, + totalGroups, + productId, + lessonDragDisabled, + canMoveUp, + canMoveDown, + sectionMoveDisabled, + onMoveUp, + onMoveDown, + onSectionMenuOpenChange, + onToggleCollapse, + onRequestDelete, +}: { + section: Group; + lessons: LessonSummary[]; + lessonInsertionCueIndex?: number | null; + collapsed: boolean; + sectionMenuOpenId: string | null; + productType?: string; + totalGroups: number; + productId: string; + lessonDragDisabled: boolean; + canMoveUp: boolean; + canMoveDown: boolean; + sectionMoveDisabled: boolean; + onMoveUp: () => void; + onMoveDown: () => void; + onSectionMenuOpenChange: (sectionId: string | null) => void; + onToggleCollapse: (sectionId: string) => void; + onRequestDelete: (item: { id: string; title: string }) => void; +}) { + const isSingleDownloadGroup = + productType?.toLowerCase() === Constants.CourseType.DOWNLOAD && + totalGroups === 1; + + return ( +
+
+
+ +
+

+ {section.name} +

+ {section.drip?.status && ( + + + + + + +

+ This section has scheduled release +

+
+
+
+ )} +
+
+
+ + + + onSectionMenuOpenChange(open ? section.id : null) + } + > + + + + + + + {EDIT_SECTION_HEADER} + + + {!isSingleDownloadGroup && ( + <> + + + onRequestDelete({ + id: section.id, + title: section.name, + }) + } + className="text-red-600" + > + Delete Section + + + )} + + +
+
+ {!collapsed && ( +
+ + +
+ )} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx new file mode 100644 index 000000000..b91da09ed --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/content-sections-board.tsx @@ -0,0 +1,513 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Group } from "@courselit/common-models"; +import { useToast } from "@courselit/components-library"; +import { FetchBuilder } from "@courselit/utils"; +import { TOAST_TITLE_ERROR } from "@ui-config/strings"; +import ContentSectionCard from "./content-section-card"; +import { + MultiContainerDragAndDrop, + MultiContainerMoveEvent, + MultiContainerSnapshot, +} from "./multi-container-drag-and-drop"; +import { + applyLessonMove, + buildLessonMap, + LessonMap, + LessonSummary, + sortLessonsForSection, +} from "./helpers"; + +const arrayMove = (items: T[], oldIndex: number, newIndex: number): T[] => { + const next = [...items]; + const [moved] = next.splice(oldIndex, 1); + if (!moved) { + return items; + } + next.splice(newIndex, 0, moved); + return next; +}; + +const findLessonLocation = ( + map: LessonMap, + lessonId: string, +): { sectionId: string; index: number } | null => { + for (const [sectionId, sectionLessons] of Object.entries(map)) { + const index = sectionLessons.findIndex( + (lesson) => lesson.lessonId === lessonId, + ); + if (index !== -1) { + return { + sectionId, + index, + }; + } + } + + return null; +}; + +const normalizeDestinationIndex = ({ + map, + sourceSectionId, + destinationSectionId, + destinationIndex, +}: { + map: LessonMap; + sourceSectionId: string; + destinationSectionId: string; + destinationIndex: number; +}) => { + const sourceLessons = map[sourceSectionId] ?? []; + const destinationLessons = map[destinationSectionId] ?? []; + const maxIndex = + sourceSectionId === destinationSectionId + ? Math.max(sourceLessons.length - 1, 0) + : destinationLessons.length; + + return Math.min(Math.max(destinationIndex, 0), maxIndex); +}; + +export default function ContentSectionsBoard({ + orderedSections, + setOrderedSections, + lessons, + courseId, + productId, + productType, + address, + onRequestDelete, +}: { + orderedSections: Group[]; + setOrderedSections: React.Dispatch>; + lessons: LessonSummary[]; + courseId: string; + productId: string; + productType?: string; + address: string; + onRequestDelete: (item: { id: string; title: string }) => void; +}) { + const { toast } = useToast(); + const [collapsedSections, setCollapsedSections] = useState([]); + const [sectionMenuOpenId, setSectionMenuOpenId] = useState( + null, + ); + const [isReordering, setIsReordering] = useState(false); + const [isMovingLesson, setIsMovingLesson] = useState(false); + const [lessonMap, setLessonMap] = useState({}); + const [activeLessonDrag, setActiveLessonDrag] = useState<{ + lessonId: string; + sourceSectionId: string; + } | null>(null); + const [focusedSectionId, setFocusedSectionId] = useState( + null, + ); + const [recentlyMovedSectionId, setRecentlyMovedSectionId] = useState< + string | null + >(null); + const sectionRefs = useRef>({}); + const [lessonHoverPreview, setLessonHoverPreview] = useState<{ + lessonId: string; + destinationSectionId: string; + destinationIndex: number; + } | null>(null); + + useEffect(() => { + setLessonMap(buildLessonMap(orderedSections, lessons)); + }, [lessons]); + + useEffect(() => { + setLessonMap((current) => { + const next = orderedSections.reduce((acc, section) => { + acc[section.id] = + current[section.id] ?? + sortLessonsForSection(lessons, section); + return acc; + }, {} as LessonMap); + + return next; + }); + }, [orderedSections, lessons]); + + useEffect(() => { + if (!focusedSectionId) { + return; + } + + const sectionNode = sectionRefs.current[focusedSectionId]; + if (!sectionNode) { + return; + } + + requestAnimationFrame(() => { + if (typeof sectionNode.scrollIntoView === "function") { + sectionNode.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + } + }); + setFocusedSectionId(null); + }, [focusedSectionId, orderedSections]); + + useEffect(() => { + if (!recentlyMovedSectionId) { + return; + } + + const timerId = window.setTimeout(() => { + setRecentlyMovedSectionId(null); + }, 900); + + return () => window.clearTimeout(timerId); + }, [recentlyMovedSectionId]); + + const disabled = isReordering || isMovingLesson; + const sectionMoveDisabled = disabled || !!activeLessonDrag; + + const toggleSectionCollapse = (sectionId: string) => { + setCollapsedSections((prev) => + prev.includes(sectionId) + ? prev.filter((id) => id !== sectionId) + : [...prev, sectionId], + ); + }; + + const reorderGroups = async ( + groupIds: string[], + fallbackSections: Group[], + ) => { + const mutation = ` + mutation ReorderGroups($courseId: String!, $groupIds: [String!]!) { + course: reorderGroups(courseId: $courseId, groupIds: $groupIds) { + courseId + groups { + id + rank + } + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address}/api/graph`) + .setPayload({ + query: mutation, + variables: { + courseId, + groupIds, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + setIsReordering(true); + try { + await fetch.exec(); + } catch (err: any) { + setOrderedSections(fallbackSections); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsReordering(false); + } + }; + + const moveLesson = async ({ + lessonId, + destinationGroupId, + destinationIndex, + rollbackSnapshot, + }: { + lessonId: string; + destinationGroupId: string; + destinationIndex: number; + rollbackSnapshot: LessonMap; + }) => { + const mutation = ` + mutation MoveLesson( + $courseId: String!, + $lessonId: String!, + $destinationGroupId: String!, + $destinationIndex: Int! + ) { + course: moveLesson( + courseId: $courseId, + lessonId: $lessonId, + destinationGroupId: $destinationGroupId, + destinationIndex: $destinationIndex + ) { + courseId + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address}/api/graph`) + .setPayload({ + query: mutation, + variables: { + courseId, + lessonId, + destinationGroupId, + destinationIndex, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + setIsMovingLesson(true); + try { + await fetch.exec(); + } catch (err: any) { + setLessonMap(rollbackSnapshot); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setIsMovingLesson(false); + setActiveLessonDrag(null); + setLessonHoverPreview(null); + } + }; + + const handleLessonOver = ({ + itemId, + destinationContainerId, + destinationIndex, + }: MultiContainerMoveEvent) => { + if ( + disabled || + !activeLessonDrag || + activeLessonDrag.lessonId !== itemId + ) { + return; + } + + if (activeLessonDrag.sourceSectionId === destinationContainerId) { + setLessonHoverPreview((prev) => (prev ? null : prev)); + return; + } + + const destinationLessons = lessonMap[destinationContainerId] ?? []; + const safeDestinationIndex = Math.min( + Math.max(destinationIndex, 0), + destinationLessons.length, + ); + + setLessonHoverPreview((prev) => { + if ( + prev && + prev.lessonId === itemId && + prev.destinationSectionId === destinationContainerId && + prev.destinationIndex === safeDestinationIndex + ) { + return prev; + } + + return { + lessonId: itemId, + destinationSectionId: destinationContainerId, + destinationIndex: safeDestinationIndex, + }; + }); + }; + + const handleLessonMove = ({ + itemId, + sourceContainerId, + sourceIndex, + destinationContainerId, + destinationIndex, + }: MultiContainerMoveEvent) => { + if (disabled) { + return; + } + + setLessonHoverPreview(null); + const currentLocation = findLessonLocation(lessonMap, itemId) ?? { + sectionId: sourceContainerId, + index: sourceIndex, + }; + const safeDestinationIndex = normalizeDestinationIndex({ + map: lessonMap, + sourceSectionId: currentLocation.sectionId, + destinationSectionId: destinationContainerId, + destinationIndex, + }); + + if ( + currentLocation.sectionId === destinationContainerId && + currentLocation.index === safeDestinationIndex + ) { + return; + } + + const rollbackSnapshot = lessonMap; + setLessonMap((current) => { + const currentLocation = findLessonLocation(current, itemId); + if (!currentLocation) { + return current; + } + + return applyLessonMove({ + current, + lessonId: itemId, + sourceSectionId: currentLocation.sectionId, + destinationSectionId: destinationContainerId, + destinationIndex: safeDestinationIndex, + }); + }); + + moveLesson({ + lessonId: itemId, + destinationGroupId: destinationContainerId, + destinationIndex: safeDestinationIndex, + rollbackSnapshot, + }); + }; + + const moveSection = (sectionId: string, offset: number) => { + if (sectionMoveDisabled) { + return; + } + + const sourceIndex = orderedSections.findIndex( + (section) => section.id === sectionId, + ); + if (sourceIndex < 0) { + return; + } + + const destinationIndex = sourceIndex + offset; + if ( + destinationIndex < 0 || + destinationIndex >= orderedSections.length + ) { + return; + } + + const fallbackSections = [...orderedSections]; + const nextSections = arrayMove( + orderedSections, + sourceIndex, + destinationIndex, + ); + setOrderedSections(nextSections); + setFocusedSectionId(sectionId); + setRecentlyMovedSectionId(sectionId); + reorderGroups( + nextSections.map((section) => section.id), + fallbackSections, + ); + }; + + const lessonContainers = useMemo( + () => + orderedSections.map((section) => ({ + containerId: section.id, + itemIds: (lessonMap[section.id] ?? []).map( + (lesson) => lesson.lessonId, + ), + })), + [orderedSections, lessonMap], + ); + + const lessonLookup = useMemo(() => { + const map = new Map(); + lessons.forEach((lesson) => { + map.set(lesson.lessonId, lesson); + }); + return map; + }, [lessons]); + + return ( + { + if (drag) { + setLessonHoverPreview(null); + setActiveLessonDrag({ + lessonId: drag.itemId, + sourceSectionId: drag.sourceContainerId, + }); + return; + } + + setActiveLessonDrag(null); + setLessonHoverPreview(null); + }} + renderDragOverlay={(itemId) => { + const item = lessonLookup.get(itemId); + if (!item) { + return null; + } + + return ( +
+ + {item.title} + +
+ ); + }} + > + {orderedSections.map((section, index) => { + const lessonInsertionCueIndex = + lessonHoverPreview && + activeLessonDrag && + lessonHoverPreview.lessonId === activeLessonDrag.lessonId && + lessonHoverPreview.destinationSectionId === section.id && + activeLessonDrag.sourceSectionId !== section.id + ? lessonHoverPreview.destinationIndex + : null; + + return ( +
{ + sectionRefs.current[section.id] = node; + }} + className={`scroll-mt-24 rounded-md transition-colors duration-300 ${ + recentlyMovedSectionId === section.id + ? "bg-primary/10 ring-1 ring-primary/40 motion-safe:animate-pulse" + : "" + }`} + > + 0} + canMoveDown={index < orderedSections.length - 1} + sectionMoveDisabled={sectionMoveDisabled} + onMoveUp={() => moveSection(section.id, -1)} + onMoveDown={() => moveSection(section.id, 1)} + onSectionMenuOpenChange={setSectionMenuOpenId} + onToggleCollapse={toggleSectionCollapse} + onRequestDelete={(item) => { + setSectionMenuOpenId(null); + onRequestDelete(item); + }} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts new file mode 100644 index 000000000..dae671221 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/helpers.ts @@ -0,0 +1,80 @@ +import { Group } from "@courselit/common-models"; +import { ProductWithAdminProps } from "@/hooks/use-product"; + +export type LessonSummary = NonNullable< + ProductWithAdminProps["lessons"] +>[number]; + +export type LessonMap = Record; + +export const sortLessonsForSection = ( + lessons: LessonSummary[], + section: Group, +): LessonSummary[] => { + return [...lessons] + .filter((lesson) => lesson.groupId === section.id) + .sort( + (a, b) => + (section.lessonsOrder ?? []).indexOf(a.lessonId) - + (section.lessonsOrder ?? []).indexOf(b.lessonId), + ); +}; + +export const buildLessonMap = ( + sections: Group[], + lessons: LessonSummary[], +): LessonMap => { + return sections.reduce((acc, section) => { + acc[section.id] = sortLessonsForSection(lessons, section); + return acc; + }, {} as LessonMap); +}; + +export const applyLessonMove = ({ + current, + lessonId, + sourceSectionId, + destinationSectionId, + destinationIndex, +}: { + current: LessonMap; + lessonId: string; + sourceSectionId: string; + destinationSectionId: string; + destinationIndex: number; +}): LessonMap => { + const sourceLessons = [...(current[sourceSectionId] ?? [])]; + const destinationLessons = + sourceSectionId === destinationSectionId + ? sourceLessons + : [...(current[destinationSectionId] ?? [])]; + + const sourceIndex = sourceLessons.findIndex( + (lesson) => lesson.lessonId === lessonId, + ); + if (sourceIndex === -1) { + return current; + } + + const [movedLesson] = sourceLessons.splice(sourceIndex, 1); + if (!movedLesson) { + return current; + } + + const updatedLesson = { + ...movedLesson, + groupId: destinationSectionId, + }; + + const safeDestinationIndex = Math.min( + Math.max(destinationIndex, 0), + destinationLessons.length, + ); + destinationLessons.splice(safeDestinationIndex, 0, updatedLesson); + + return { + ...current, + [sourceSectionId]: sourceLessons, + [destinationSectionId]: destinationLessons, + }; +}; diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx new file mode 100644 index 000000000..0b53794a0 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/product/[id]/content/components/lesson-list-item.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { ChevronRight, FileText, HelpCircle, Video } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { DragHandle } from "@courselit/icons"; +import { PRODUCT_STATUS_DRAFT } from "@ui-config/strings"; +import { CSSProperties } from "react"; +import { LessonSummary } from "./helpers"; +import type { useMultiContainerSortableItem } from "./multi-container-drag-and-drop"; + +function LessonTypeIcon({ type }: { type: string }) { + switch (type) { + case "video": + return