From 30b7dc4410e5d7032cdaa1804cfcc08d3eb4dcc5 Mon Sep 17 00:00:00 2001 From: swve Date: Fri, 16 May 2025 23:13:16 +0200 Subject: [PATCH] feat: improve performance on activity page --- .../activity/[activityid]/activity.tsx | 851 +++++++++--------- .../activity/[activityid]/page.tsx | 2 +- .../(withmenu)/course/[courseuuid]/page.tsx | 2 +- .../Activity/FixedActivitySecondaryBar.tsx | 211 +++-- .../Pages/Courses/ActivityIndicators.tsx | 180 ++-- 5 files changed, 640 insertions(+), 606 deletions(-) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index fcdc1948..eb52b567 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -1,27 +1,19 @@ 'use client' import Link from 'next/link' import { getAPIUrl, getUriWithOrg } from '@services/config/config' -import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva' -import VideoActivity from '@components/Objects/Activities/Video/Video' import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2, EllipsisVertical, Maximize2, Minimize2 } from 'lucide-react' import { markActivityAsComplete, unmarkActivityAsComplete } from '@services/courses/activity' -import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' -import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' -import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { usePathname, useRouter } from 'next/navigation' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { useOrg } from '@components/Contexts/OrgContext' import { CourseProvider } from '@components/Contexts/CourseContext' -import AIActivityAsk from '@components/Objects/Activities/AI/AIActivityAsk' -import AIChatBotProvider from '@components/Contexts/AI/AIChatBotContext' import { useLHSession } from '@components/Contexts/LHSessionContext' -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef, useMemo, lazy, Suspense } from 'react' import { getAssignmentFromActivityUUID, getFinalGrade, submitAssignmentForGrading } from '@services/courses/assignments' -import AssignmentStudentActivity from '@components/Objects/Activities/Assignment/AssignmentStudentActivity' import { AssignmentProvider } from '@components/Contexts/Assignments/AssignmentContext' import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/AssignmentsTaskContext' -import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext' +import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext' import toast from 'react-hot-toast' import { mutate } from 'swr' import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' @@ -36,7 +28,26 @@ import CourseEndView from '@components/Pages/Activity/CourseEndView' import { motion, AnimatePresence } from 'framer-motion' import ActivityBreadcrumbs from '@components/Pages/Activity/ActivityBreadcrumbs' import MiniInfoTooltip from '@components/Objects/MiniInfoTooltip' +import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' +import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' +// Lazy load heavy components +const Canva = lazy(() => import('@components/Objects/Activities/DynamicCanva/DynamicCanva')) +const VideoActivity = lazy(() => import('@components/Objects/Activities/Video/Video')) +const DocumentPdfActivity = lazy(() => import('@components/Objects/Activities/DocumentPdf/DocumentPdf')) +const AssignmentStudentActivity = lazy(() => import('@components/Objects/Activities/Assignment/AssignmentStudentActivity')) +const AIActivityAsk = lazy(() => import('@components/Objects/Activities/AI/AIActivityAsk')) +const AIChatBotProvider = lazy(() => import('@components/Contexts/AI/AIChatBotContext')) + +// Loading fallback component +const LoadingFallback = () => ( +
+
+
+
+
+
+); interface ActivityClientProps { activityid: string @@ -55,6 +66,31 @@ interface ActivityActionsProps { showNavigation?: boolean } +// Custom hook for activity position +function useActivityPosition(course: any, activityId: string) { + return useMemo(() => { + let allActivities: any[] = []; + let currentIndex = -1; + + course.chapters.forEach((chapter: any) => { + chapter.activities.forEach((activity: any) => { + const cleanActivityUuid = activity.activity_uuid?.replace('activity_', ''); + allActivities.push({ + ...activity, + cleanUuid: cleanActivityUuid, + chapterName: chapter.name + }); + + if (cleanActivityUuid === activityId.replace('activity_', '')) { + currentIndex = allActivities.length - 1; + } + }); + }); + + return { allActivities, currentIndex }; + }, [course, activityId]); +} + function ActivityActions({ activity, activityid, course, orgslug, assignment, showNavigation = true }: ActivityActionsProps) { const session = useLHSession() as any; const { contributorStatus } = useContributorStatus(course.course_uuid); @@ -113,37 +149,55 @@ function ActivityClient(props: ActivityClientProps) { const { contributorStatus } = useContributorStatus(courseuuid); const router = useRouter(); - // Function to find the current activity's position in the course - const findActivityPosition = () => { - let allActivities: any[] = []; - let currentIndex = -1; - - // Flatten all activities from all chapters - course.chapters.forEach((chapter: any) => { - chapter.activities.forEach((activity: any) => { - const cleanActivityUuid = activity.activity_uuid?.replace('activity_', ''); - allActivities.push({ - ...activity, - cleanUuid: cleanActivityUuid, - chapterName: chapter.name - }); - - // Check if this is the current activity - if (cleanActivityUuid === activityid.replace('activity_', '')) { - currentIndex = allActivities.length - 1; - } - }); - }); - - return { allActivities, currentIndex }; - }; - - const { allActivities, currentIndex } = findActivityPosition(); + // Memoize activity position calculation + const { allActivities, currentIndex } = useActivityPosition(course, activityid); // Get previous and next activities const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null; const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null; - + + // Memoize activity content + const activityContent = useMemo(() => { + if (!activity || !activity.published || activity.content.paid_access === false) { + return null; + } + + switch (activity.activity_type) { + case 'TYPE_DYNAMIC': + return ( + }> + + + ); + case 'TYPE_VIDEO': + return ( + }> + + + ); + case 'TYPE_DOCUMENT': + return ( + }> + + + ); + case 'TYPE_ASSIGNMENT': + return assignment ? ( + }> + + + + + + + + + ) : null; + default: + return null; + } + }, [activity, course, assignment]); + // Navigate to an activity const navigateToActivity = (activity: any) => { if (!activity) return; @@ -208,259 +262,57 @@ function ActivityClient(props: ActivityClientProps) { return ( <> - - {isFocusMode ? ( - - - {/* Focus Mode Top Bar */} + }> + + {isFocusMode ? ( + -
-
-
- setIsFocusMode(false)} - className="bg-white nice-shadow p-2 rounded-full cursor-pointer hover:bg-gray-50" - title="Exit focus mode" - > - - - -
- - {/* Center Course Info */} - -
- - - -
-
-

Course

-

- {course.name} -

-
-
- - {/* Progress Indicator */} - -
- - - run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0) / (course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 1))} - /> - -
- - {Math.round(((course.trail?.runs?.find((run: any) => run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0) / (course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 1)) * 100)}% - -
-
-
- {course.trail?.runs?.find((run: any) => run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0} of {course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 0} -
-
-
-
-
- - {/* Focus Mode Content */} -
-
- {activity && activity.published == true && ( - <> - {activity.content.paid_access == false ? ( - - ) : ( - - {/* Activity Types */} -
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( -
- {assignment ? ( - - - - - - - - ) : ( -
- )} -
- )} -
-
- )} - - )} -
-
- - {/* Focus Mode Bottom Bar */} - {activity && activity.published == true && activity.content.paid_access != false && ( + {/* Focus Mode Top Bar */} -
-
+
+
- -
-
- + + -
-
-
- - )} - - - ) : ( - - {/* Original non-focus mode UI */} - {activityid === 'end' ? ( - - ) : ( -
-
- -
-
-
+ + {/* Center Course Info */} +
-

Course

-

+

Course

+

{course.name}

-
- {activity && activity.published == true && activity.content.paid_access != false && ( - - { ( -
- - -
- )} -
- )} + + + {/* Progress Indicator */} + +
+ + + run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0) / (course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 1))} + /> + +
+ + {Math.round(((course.trail?.runs?.find((run: any) => run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0) / (course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 1)) * 100)}% + +
+
+
+ {course.trail?.runs?.find((run: any) => run.course_id === course.id)?.steps?.filter((step: any) => step.complete)?.length || 0} of {course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 0} +
+
+
+ - + {/* Focus Mode Content */} +
+
+ {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( + + {/* Activity Types */} +
+ {activityContent} +
+
+ )} + + )} +
+
-
-
- - -
-

- Chapter : {getChapterNameByActivityId(course, activity.id)} -

-

- {activity.name} -

+ {/* Focus Mode Bottom Bar */} + {activity && activity.published == true && activity.content.paid_access != false && ( + +
+
+
+ +
+
+ +
-
+
+ + )} + + + ) : ( + + {/* Original non-focus mode UI */} + {activityid === 'end' ? ( + + ) : ( +
+
+ +
+
+
+
+ + + +
+
+

Course

+

+ {course.name} +

+
+
{activity && activity.published == true && activity.content.paid_access != false && ( - {activity.activity_type != 'TYPE_ASSIGNMENT' && ( - <> - - {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( - - - Contribute - - )} - + { ( +
+ + +
)}
)}
-
-
- {activity && activity.published == false && ( -
-
-

- This activity is not published yet -

-
-
- )} + - {activity && activity.published == true && ( - <> - {activity.content.paid_access == false ? ( - - ) : ( -
- {/* Activity Types */} -
- {activity.activity_type == 'TYPE_DYNAMIC' && ( - - )} - {activity.activity_type == 'TYPE_VIDEO' && ( - - )} - {activity.activity_type == 'TYPE_DOCUMENT' && ( - - )} - {activity.activity_type == 'TYPE_ASSIGNMENT' && ( -
- {assignment ? ( - - - - - - - - ) : ( -
- )} -
- )} +
+
+ + +
+

+ Chapter : {getChapterNameByActivityId(course, activity.id)} +

+

+ {activity.name} +

- )} - - )} - - {/* Activity Actions below the content box */} - {activity && activity.published == true && activity.content.paid_access != false && ( -
-
- -
-
- - +
+ {activity && activity.published == true && activity.content.paid_access != false && ( + + {activity.activity_type != 'TYPE_ASSIGNMENT' && ( + <> + + {contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && ( + + + Contribute + + )} + + )} + + )} +
- )} - {/* Fixed Activity Secondary Bar */} - {activity && activity.published == true && activity.content.paid_access != false && ( - - )} - -
+ {activity && activity.published == false && ( +
+
+

+ This activity is not published yet +

+
+
+ )} + + {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( +
+ {activityContent} +
+ )} + + )} + + {/* Activity Actions below the content box */} + {activity && activity.published == true && activity.content.paid_access != false && ( +
+
+ +
+
+ + +
+
+ )} + + {/* Fixed Activity Secondary Bar */} + {activity && activity.published == true && activity.content.paid_access != false && ( + + )} + +
+
-
- )} - - )} - + )} + + )} + + ) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx index 470251d0..48bac3cf 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx @@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => { fetchCourseMetadata(courseuuid, access_token), getActivityWithAuthHeader( activityid, - { revalidate: 0, tags: ['activities'] }, + { revalidate: 60, tags: ['activities'] }, access_token || null ) ]) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx index e3216016..df4fbccd 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/page.tsx @@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise }) const course_meta = await getCourseMetadata( params.courseuuid, - { revalidate: 1800, tags: ['courses'] }, + { revalidate: 0, tags: ['courses'] }, access_token ? access_token : null ) diff --git a/apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx b/apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx index ae86377a..3c6f6217 100644 --- a/apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx +++ b/apps/web/components/Pages/Activity/FixedActivitySecondaryBar.tsx @@ -2,7 +2,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react' import { getUriWithOrg } from '@services/config/config' import { useRouter } from 'next/navigation' -import React, { useEffect, useState, useRef } from 'react' +import React, { useEffect, useState, useRef, useMemo, memo } from 'react' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import ActivityChapterDropdown from './ActivityChapterDropdown' import { getCourseThumbnailMediaDirectory } from '@services/media/media' @@ -15,6 +15,86 @@ interface FixedActivitySecondaryBarProps { activity: any } +// Memoized navigation buttons component +const NavigationButtons = memo(({ + prevActivity, + nextActivity, + currentIndex, + allActivities, + navigateToActivity +}: { + prevActivity: any, + nextActivity: any, + currentIndex: number, + allActivities: any[], + navigateToActivity: (activity: any) => void +}) => ( +
+ + + + {currentIndex + 1} of {allActivities.length} + + + +
+)); + +NavigationButtons.displayName = 'NavigationButtons'; + +// Memoized course info component +const CourseInfo = memo(({ course, org }: { course: any, org: any }) => ( +
+ +
+

Course

+

+ {course.name} +

+
+
+)); + +CourseInfo.displayName = 'CourseInfo'; + export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryBarProps): React.ReactNode { const router = useRouter(); const [isScrolled, setIsScrolled] = useState(false); @@ -22,12 +102,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB const mainActivityInfoRef = useRef(null); const org = useOrg() as any; - // Function to find the current activity's position in the course - const findActivityPosition = () => { + // Memoize activity position calculation + const { allActivities, currentIndex } = useMemo(() => { let allActivities: any[] = []; let currentIndex = -1; - // Flatten all activities from all chapters props.course.chapters.forEach((chapter: any) => { chapter.activities.forEach((activity: any) => { const cleanActivityUuid = activity.activity_uuid?.replace('activity_', ''); @@ -37,7 +116,6 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB chapterName: chapter.name }); - // Check if this is the current activity if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) { currentIndex = allActivities.length - 1; } @@ -45,15 +123,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB }); return { allActivities, currentIndex }; - }; + }, [props.course, props.currentActivityId]); - const { allActivities, currentIndex } = findActivityPosition(); - - // Get previous and next activities const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null; const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null; - // Navigate to an activity const navigateToActivity = (activity: any) => { if (!activity) return; @@ -61,32 +135,26 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`); }; - // Handle scroll and intersection observer useEffect(() => { const handleScroll = () => { setIsScrolled(window.scrollY > 0); }; - // Set up intersection observer for the main activity info const observer = new IntersectionObserver( ([entry]) => { - // Show the fixed bar when the main info is not visible setShouldShow(!entry.isIntersecting); }, { threshold: [0, 0.1, 1], - rootMargin: '-80px 0px 0px 0px' // Increased margin to account for the header + rootMargin: '-80px 0px 0px 0px' } ); - // Start observing the main activity info section with a slight delay to ensure DOM is ready - setTimeout(() => { - const mainActivityInfo = document.querySelector('.activity-info-section'); - if (mainActivityInfo) { - mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement; - observer.observe(mainActivityInfo); - } - }, 100); + const mainActivityInfo = document.querySelector('.activity-info-section'); + if (mainActivityInfo) { + mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement; + observer.observe(mainActivityInfo); + } window.addEventListener('scroll', handleScroll); @@ -98,86 +166,29 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB }; }, []); + if (!shouldShow) return null; + return ( - <> - {shouldShow && ( -
-
-
- {/* Left Section - Course Info and Navigation */} -
- - -
-

Course

-

- {props.course.name} -

-
-
- - {/* Right Section - Navigation Controls */} -
-
- - - - {currentIndex + 1} of {allActivities.length} - - - -
-
-
+
+
+
+ + +
+
- )} - +
+
); } \ No newline at end of file diff --git a/apps/web/components/Pages/Courses/ActivityIndicators.tsx b/apps/web/components/Pages/Courses/ActivityIndicators.tsx index 8e47f7f7..bfe3be7d 100644 --- a/apps/web/components/Pages/Courses/ActivityIndicators.tsx +++ b/apps/web/components/Pages/Courses/ActivityIndicators.tsx @@ -1,16 +1,99 @@ +'use client' +import { BookOpenCheck, Check, FileText, Layers, Video } from 'lucide-react' +import React, { useMemo, memo } from 'react' import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import { getUriWithOrg } from '@services/config/config' import Link from 'next/link' -import React from 'react' -import { Video, FileText, Layers, BookOpenCheck, Check } from 'lucide-react' interface Props { course: any orgslug: string course_uuid: string - current_activity?: any + current_activity?: string } +// Helper functions +function getActivityTypeLabel(activityType: string): string { + switch (activityType) { + case 'TYPE_VIDEO': + return 'Video' + case 'TYPE_DOCUMENT': + return 'Document' + case 'TYPE_DYNAMIC': + return 'Interactive' + case 'TYPE_ASSIGNMENT': + return 'Assignment' + default: + return 'Unknown' + } +} + +function getActivityTypeBadgeColor(activityType: string): string { + switch (activityType) { + case 'TYPE_VIDEO': + return 'bg-blue-100 text-blue-700' + case 'TYPE_DOCUMENT': + return 'bg-purple-100 text-purple-700' + case 'TYPE_DYNAMIC': + return 'bg-green-100 text-green-700' + case 'TYPE_ASSIGNMENT': + return 'bg-orange-100 text-orange-700' + default: + return 'bg-gray-100 text-gray-700' + } +} + +// Memoized activity type icon component +const ActivityTypeIcon = memo(({ activityType }: { activityType: string }) => { + switch (activityType) { + case 'TYPE_VIDEO': + return