diff --git a/apps/api/src/core/events/database.py b/apps/api/src/core/events/database.py index 6733dd81..8c703c6e 100644 --- a/apps/api/src/core/events/database.py +++ b/apps/api/src/core/events/database.py @@ -36,8 +36,8 @@ engine = create_engine( learnhouse_config.database_config.sql_connection_string, # type: ignore echo=False, pool_pre_ping=True, # type: ignore - pool_size=10, - max_overflow=20, + pool_size=5, + max_overflow=0, pool_recycle=300, # Recycle connections after 5 minutes pool_timeout=30 ) diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 444a8e4e..9922a57c 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -133,42 +133,40 @@ async def get_course_meta( # Avoid circular import from src.services.courses.chapters import get_course_chapters - # Get course with a single query - course_statement = select(Course).where(Course.course_uuid == course_uuid) - course = db_session.exec(course_statement).first() + # Get course with authors in a single query using joins + course_statement = ( + select(Course, ResourceAuthor, User) + .outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore + .outerjoin(User, ResourceAuthor.user_id == User.id) # type: ignore + .where(Course.course_uuid == course_uuid) + .order_by(ResourceAuthor.id.asc()) # type: ignore + ) + results = db_session.exec(course_statement).all() - if not course: + if not results: raise HTTPException( status_code=404, detail="Course not found", ) + # Extract course and authors from results + course = results[0][0] # First result's Course + author_results = [(ra, u) for _, ra, u in results if ra is not None and u is not None] + # RBAC check await rbac_check(request, course.course_uuid, current_user, "read", db_session) # Start async tasks concurrently tasks = [] - # Task 1: Get course authors with their roles - async def get_authors(): - authors_statement = ( - select(ResourceAuthor, User) - .join(User, ResourceAuthor.user_id == User.id) # type: ignore - .where(ResourceAuthor.resource_uuid == course.course_uuid) - .order_by( - ResourceAuthor.id.asc() # type: ignore - ) - ) - return db_session.exec(authors_statement).all() - - # Task 2: Get course chapters + # Task 1: Get course chapters async def get_chapters(): # Ensure course.id is not None if course.id is None: return [] return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities) - # Task 3: Get user trail (only for authenticated users) + # Task 2: Get user trail (only for authenticated users) async def get_trail(): if isinstance(current_user, AnonymousUser): return None @@ -177,12 +175,11 @@ async def get_course_meta( ) # Add tasks to the list - tasks.append(get_authors()) tasks.append(get_chapters()) tasks.append(get_trail()) # Run all tasks concurrently - author_results, chapters, trail = await asyncio.gather(*tasks) + chapters, trail = await asyncio.gather(*tasks) # Convert to AuthorWithRole objects authors = [ diff --git a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx index 85030599..6ac8785e 100644 --- a/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx +++ b/apps/web/app/editor/course/[courseid]/activity/[activityuuid]/edit/page.tsx @@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise // Get Org context information const course_meta = await getCourseMetadata( params.courseid, - { revalidate: 0, tags: ['courses'] }, + { revalidate: 30, tags: ['courses'] }, access_token ? access_token : null ) @@ -41,7 +41,7 @@ const EditActivity = async (params: any) => { const courseid = (await params.params).courseid const courseInfo = await getCourseMetadata( courseid, - { revalidate: 0, tags: ['courses'] }, + { revalidate: 30, tags: ['courses'] }, access_token ? access_token : null ) const activity = await getActivityWithAuthHeader( 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 c629dbf3..8c8546d4 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 { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } 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' @@ -34,6 +26,30 @@ import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterD import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar' 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' +import { revalidateTags } from '@services/utils/ts/requests' +import UserAvatar from '@components/Objects/UserAvatar' + +// 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 @@ -52,6 +68,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); @@ -92,6 +133,26 @@ function ActivityActions({ activity, activityid, course, orgslug, assignment, sh ); } +function getRelativeTime(date: Date): string { + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + const weeks = Math.floor(days / 7); + const months = Math.floor(days / 30); + const years = Math.floor(days / 365); + + if (years > 0) return `${years} year${years > 1 ? 's' : ''} ago`; + if (months > 0) return `${months} month${months > 1 ? 's' : ''} ago`; + if (weeks > 0) return `${weeks} week${weeks > 1 ? 's' : ''} ago`; + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + return 'just now'; +} + function ActivityClient(props: ActivityClientProps) { const activityid = props.activityid const courseuuid = props.courseuuid @@ -110,37 +171,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; @@ -205,254 +284,80 @@ 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' ? ( - - ) : ( -
-
-
-
-
+
+
+ {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} +
+ + + {/* Center Course Info */} +
-

Course

-

+

Course

+

{course.name}

-
- {activity && activity.published == true && activity.content.paid_access != false && ( - - { ( -
- - -
- )} -
- )} -
+ - - -
-
- + {/* Minimize and Chapters - Moved to right */} + -
-

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

-

- {activity.name} -

+ setIsFocusMode(false)} + className="bg-white nice-shadow p-2 rounded-full cursor-pointer hover:bg-gray-50" + title="Exit focus mode" + > + + + +
+
+ + + {/* Focus Mode Content */} +
+
+ {activity && activity.published == true && ( + <> + {activity.content.paid_access == false ? ( + + ) : ( + + {/* Activity Types */} +
+ {activityContent} +
+
+ )} + + )} +
+
+ + {/* 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} +

+ {/* Authors and Dates Section */} +
+ {/* Avatars */} + {course.authors && course.authors.length > 0 && ( +
+ {course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').slice(0, 3).map((author: any, idx: number) => ( +
+ +
+ ))} + {course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 3 && ( +
+ +{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 3} +
+ )} +
+ )} + {/* Author names */} + {course.authors && course.authors.length > 0 && ( +
+ {course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 1 && ( + Co-created by + )} + {course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').slice(0, 2).map((author: any, idx: number, arr: any[]) => ( + + {author.user.first_name && author.user.last_name + ? `${author.user.first_name} ${author.user.last_name}` + : `@${author.user.username}`} + {idx === 0 && arr.length > 1 ? ' & ' : ''} + + ))} + {course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 2 && ( + + {course.authors + .filter((a: any) => a.authorship_status === 'ACTIVE') + .slice(2) + .map((author: any) => ( +
+ {author.user.first_name && author.user.last_name + ? `${author.user.first_name} ${author.user.last_name}` + : `@${author.user.username}`} +
+ ))} +
+ } + > +
+ +{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 2} +
+ + )} +
+ )} + {/* Dates */} +
+ + Created on {new Date(course.creation_date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })} + + + + Last updated {getRelativeTime(new Date(course.updated_at || course.last_updated || course.creation_date))} +
- )} +
- )} - - )} - - {/* 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 && ( + + )} + +
+
-
- )} -
- )} - + )} + + )} + + ) @@ -638,6 +770,51 @@ export function MarkStatus(props: { const session = useLHSession() as any; const isMobile = useMediaQuery('(max-width: 768px)') const [isLoading, setIsLoading] = React.useState(false); + const [showMarkedTooltip, setShowMarkedTooltip] = React.useState(false); + const [showUnmarkedTooltip, setShowUnmarkedTooltip] = React.useState(false); + + React.useEffect(() => { + if (typeof window !== 'undefined') { + const markedTooltipCount = localStorage.getItem('activity_marked_tooltip_count'); + const unmarkedTooltipCount = localStorage.getItem('activity_unmarked_tooltip_count'); + + if (!markedTooltipCount || parseInt(markedTooltipCount) < 3) { + setShowMarkedTooltip(true); + } + if (!unmarkedTooltipCount || parseInt(unmarkedTooltipCount) < 3) { + setShowUnmarkedTooltip(true); + } + } + }, []); + + const handleMarkedTooltipClose = () => { + if (typeof window !== 'undefined') { + localStorage.setItem('activity_marked_tooltip_count', '3'); + setShowMarkedTooltip(false); + } + }; + + const handleUnmarkedTooltipClose = () => { + if (typeof window !== 'undefined') { + localStorage.setItem('activity_unmarked_tooltip_count', '3'); + setShowUnmarkedTooltip(false); + } + }; + + const infoIcon = ( + + + + + + ); const areAllActivitiesCompleted = () => { const run = props.course.trail.runs.find( @@ -648,7 +825,6 @@ export function MarkStatus(props: { let totalActivities = 0; let completedActivities = 0; - // Count all activities and completed activities props.course.chapters.forEach((chapter: any) => { chapter.activities.forEach((activity: any) => { totalActivities++; @@ -661,22 +837,16 @@ export function MarkStatus(props: { }); }); - console.log('Total activities:', totalActivities); - console.log('Completed activities:', completedActivities); - console.log('All completed?', completedActivities >= totalActivities - 1); - - // We check for totalActivities - 1 because the current activity completion - // hasn't been counted yet (it's in progress) return completedActivities >= totalActivities - 1; }; async function markActivityAsCompleteFront() { try { - // Check if this will be the last activity to complete const willCompleteAll = areAllActivitiesCompleted(); - console.log('Will complete all?', willCompleteAll); - setIsLoading(true); + // refresh the page after marking the activity as complete + await revalidateTags(['courses'], props.orgslug); + router.refresh(); await markActivityAsComplete( props.orgslug, props.course.course_uuid, @@ -684,11 +854,9 @@ export function MarkStatus(props: { session.data?.tokens?.access_token ); - // Mutate the course data await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); if (willCompleteAll) { - console.log('Redirecting to end page...'); const cleanCourseUuid = props.course.course_uuid.replace('course_', ''); router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`); } else { @@ -705,14 +873,13 @@ export function MarkStatus(props: { async function unmarkActivityAsCompleteFront() { try { setIsLoading(true); - const trail = await unmarkActivityAsComplete( + await unmarkActivityAsComplete( props.orgslug, props.course.course_uuid, props.activity.activity_uuid, session.data?.tokens?.access_token ); - - // Mutate the course data to trigger re-render + await revalidateTags(['courses'], props.orgslug); await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); router.refresh(); } catch (error) { @@ -737,24 +904,13 @@ export function MarkStatus(props: { <> {isActivityCompleted() ? (
-
- - - {' '} - Complete -
- +
+
{isLoading ? (
@@ -763,34 +919,78 @@ export function MarkStatus(props: {
) : ( - + + + + )} + Complete
} functionToExecute={unmarkActivityAsCompleteFront} status="warning" /> - + {showMarkedTooltip && ( + + )} +
) : (
-
- {isLoading ? ( -
- - - +
+
+ {isLoading ? ( +
+ + + + +
+ ) : ( + + -
- ) : ( - - - - )}{' '} - {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}} + )} + {isLoading ? 'Marking...' : 'Mark as complete'} +
+ {showUnmarkedTooltip && ( + + )}
)} 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..51e1541f 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 @@ -21,7 +21,7 @@ type Session = { async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) { return await getCourseMetadata( courseuuid, - { revalidate: 0, tags: ['courses'] }, + { revalidate: 30, tags: ['courses'] }, access_token || null ) } @@ -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]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index 41dfbbb1..89ceccc7 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -17,6 +17,7 @@ import { useMediaQuery } from 'usehooks-ts' import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions' import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile' import CourseAuthors from '@components/Objects/Courses/CourseAuthors/CourseAuthors' +import CourseBreadcrumbs from '@components/Pages/Courses/CourseBreadcrumbs' const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) @@ -127,7 +128,11 @@ const CourseClient = (props: any) => { ) : ( <> -
+ +

Course

{course.name}

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..e5d06129 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: 30, tags: ['courses'] }, access_token ? access_token : null ) @@ -72,7 +72,7 @@ const CoursePage = async (params: any) => { // Fetch course metadata once const course_meta = await getCourseMetadata( params.params.courseuuid, - { revalidate: 0, tags: ['courses'] }, + { revalidate: 30, tags: ['courses'] }, access_token ? access_token : null ) diff --git a/apps/web/components/Objects/Activities/DynamicCanva/CustomHeadingExtenstion.tsx b/apps/web/components/Objects/Activities/DynamicCanva/CustomHeadingExtenstion.tsx new file mode 100644 index 00000000..2e655434 --- /dev/null +++ b/apps/web/components/Objects/Activities/DynamicCanva/CustomHeadingExtenstion.tsx @@ -0,0 +1,29 @@ +import Heading from '@tiptap/extension-heading' + +// Custom Heading extension that adds IDs +export const CustomHeading = Heading.extend({ + renderHTML({ node, HTMLAttributes }: { node: any; HTMLAttributes: any }) { + const hasLevel = this.options.levels.includes(node.attrs.level) + const level = hasLevel ? node.attrs.level : this.options.levels[0] + + // Generate ID from heading text + const headingText = node.textContent || '' + const slug = headingText + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special characters + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + + const id = slug ? `heading-${slug}` : `heading-${Math.random().toString(36).substr(2, 9)}` + + return [ + `h${level}`, + { + ...HTMLAttributes, + id, + }, + 0, + ] + }, +}) \ No newline at end of file diff --git a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx index 1c07b5eb..f2f84439 100644 --- a/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx +++ b/apps/web/components/Objects/Activities/DynamicCanva/DynamicCanva.tsx @@ -33,12 +33,16 @@ import TableRow from '@tiptap/extension-table-row' import TableCell from '@tiptap/extension-table-cell' import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock' import { getLinkExtension } from '@components/Objects/Editor/EditorConf' +import TableOfContents from './TableOfContents' +import { CustomHeading } from './CustomHeadingExtenstion' interface Editor { content: string activity: any } + + function Canva(props: Editor) { /** * Important Note : This is a workaround to enable user interaction features to be implemented easily, like text selection, AI features and other planned features, this is set to true but otherwise it should be set to false. @@ -59,6 +63,7 @@ function Canva(props: Editor) { editable: isEditable, extensions: [ StarterKit.configure({ + heading: false, bulletList: { HTMLAttributes: { class: 'bullet-list', @@ -70,6 +75,7 @@ function Canva(props: Editor) { }, }, }), + CustomHeading, NoTextInput, // Custom Extensions InfoCallout.configure({ @@ -137,7 +143,10 @@ function Canva(props: Editor) { - + + + + ) @@ -146,33 +155,17 @@ function Canva(props: Editor) { const CanvaWrapper = styled.div` width: 100%; margin: 0 auto; +` - .bubble-menu { - display: flex; - background-color: #0d0d0d; - padding: 0.2rem; - border-radius: 0.5rem; - - button { - border: none; - background: none; - color: #fff; - font-size: 0.85rem; - font-weight: 500; - padding: 0 0.2rem; - opacity: 0.6; - - &:hover, - &.is-active { - opacity: 1; - } - } - } - - // disable chrome outline +const ContentWrapper = styled.div` + display: flex; + width: 100%; + height: 100%; .ProseMirror { - // Workaround to disable editor from being edited by the user. + flex: 1; + padding: 1rem; + // disable chrome outline caret-color: transparent; h1 { diff --git a/apps/web/components/Objects/Activities/DynamicCanva/TableOfContents.tsx b/apps/web/components/Objects/Activities/DynamicCanva/TableOfContents.tsx new file mode 100644 index 00000000..72243266 --- /dev/null +++ b/apps/web/components/Objects/Activities/DynamicCanva/TableOfContents.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react' +import styled from 'styled-components' +import { Editor } from '@tiptap/react' +import { Check } from 'lucide-react' + +interface TableOfContentsProps { + editor: Editor | null +} + +interface HeadingItem { + level: number + text: string + id: string +} + + +const TableOfContents = ({ editor }: TableOfContentsProps) => { + const [headings, setHeadings] = useState([]) + const [open, setOpen] = useState(true) + + useEffect(() => { + if (!editor) return + + const updateHeadings = () => { + const items: HeadingItem[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name.startsWith('heading')) { + const level = node.attrs.level || 1 + const headingText = node.textContent || '' + + // Create slug from heading text (same logic as CustomHeading in DynamicCanva) + const slug = headingText + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Remove special characters + .replace(/[\s_-]+/g, '-') // Replace spaces and underscores with hyphens + .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens + + const id = slug ? `heading-${slug}` : `heading-${Math.random().toString(36).substr(2, 9)}` + + items.push({ + level, + text: node.textContent, + id, + }) + } + }) + setHeadings(items) + } + + editor.on('update', updateHeadings) + updateHeadings() + + return () => { + editor.off('update', updateHeadings) + } + }, [editor]) + + if (headings.length === 0) return null + + return ( + + + {headings.map((heading, index) => ( + + + {heading.text} + + ))} + + + ) +} + +const TOCCard = styled.div` + width: 20%; + background: none; + border: none; + box-shadow: none; + padding: 0; + margin: 0; + font-family: inherit; + display: flex; + flex-direction: column; + align-items: stretch; + height: fit-content; +` + +const TOCList = styled.ul` + list-style: none !important; + padding: 0 !important; + margin: 0; +` + +const TOCItem = styled.li<{ level: number }>` + margin: 0.5rem 0; + padding-left: ${({ level }) => `${(level - 1) * 1.2}rem`}; + list-style: none !important; + display: flex; + align-items: flex-start; + gap: 0.5rem; + + .toc-check { + display: flex; + align-items: center; + color: #23272f; + flex-shrink: 0; + margin-top: 0.1rem; + } + + .toc-link { + color: #23272f; + text-decoration: none; + display: block; + font-size: ${({ level }) => (level === 1 ? '1rem' : level === 2 ? '0.97rem' : '0.95rem')}; + font-weight: ${({ level }) => (level === 1 ? 500 : 400)}; + line-height: 1.4; + padding: 0; + background: none; + border-radius: 0; + transition: none; + flex: 1; + min-width: 0; + word-break: break-word; + hyphens: auto; + + &:hover { + color: #007acc; + } + } +` + +export default TableOfContents \ No newline at end of file diff --git a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx index c559f377..defb5045 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx @@ -197,6 +197,7 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil if (firstActivity) { // Redirect to the first activity + await revalidateTags(['activities'], orgslug) router.push( getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}` @@ -209,6 +210,7 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil console.error('Failed to perform course action:', error) } finally { setIsActionLoading(false) + await revalidateTags(['courses'], orgslug) } } diff --git a/apps/web/components/Objects/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx b/apps/web/components/Objects/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx index 12317e3a..ebb4e82e 100644 --- a/apps/web/components/Objects/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx +++ b/apps/web/components/Objects/Editor/Extensions/Callout/Info/InfoCalloutComponent.tsx @@ -1,6 +1,6 @@ import { useEditorProvider } from '@components/Contexts/Editor/EditorContext' import { NodeViewContent, NodeViewWrapper } from '@tiptap/react' -import { AlertCircle, X } from 'lucide-react' +import { AlertCircle, Info, X } from 'lucide-react' import React, { useState } from 'react' import styled from 'styled-components' @@ -100,11 +100,11 @@ function InfoCalloutComponent(props: any) { const getVariantClasses = () => { switch(options.variant) { case 'filled': - return 'bg-blue-500 text-white'; + return 'bg-gray-300 text-gray-700'; case 'outlined': - return 'bg-transparent border-2 border-blue-500 text-blue-700'; + return 'bg-transparent border-2 border-gray-300 text-gray-500'; default: - return 'bg-blue-200 text-blue-900'; + return 'bg-gray-100 text-gray-600'; } } @@ -119,12 +119,12 @@ function InfoCalloutComponent(props: any) { return ( - + diff --git a/apps/web/components/Objects/Loaders/PageLoading.tsx b/apps/web/components/Objects/Loaders/PageLoading.tsx index d0390339..a2aff1c8 100644 --- a/apps/web/components/Objects/Loaders/PageLoading.tsx +++ b/apps/web/components/Objects/Loaders/PageLoading.tsx @@ -1,64 +1,41 @@ 'use client' +import { Loader2 } from 'lucide-react' import { motion } from 'framer-motion' -const variants = { - hidden: { opacity: 0, x: 0, y: 0 }, - enter: { opacity: 1, x: 0, y: 0 }, - exit: { opacity: 0, x: 0, y: 0 }, -} - -// Animation variants for the dots -const dotVariants = { - initial: { scale: 0.8, opacity: 0.4 }, - animate: (i: number) => ({ - scale: [0.8, 1.2, 0.8], - opacity: [0.4, 1, 0.4], - transition: { - duration: 1.5, - repeat: Infinity, - delay: i * 0.2, - ease: "easeInOut" - } - }) -} - function PageLoading() { return ( - -
-
- {/* Animated dots */} -
- {[0, 1, 2, 3, 4].map((i) => ( - - ))} -
- - - Loading... - -
-
-
+
+ + + +
) } diff --git a/apps/web/components/Objects/MiniInfoTooltip.tsx b/apps/web/components/Objects/MiniInfoTooltip.tsx new file mode 100644 index 00000000..6e27890e --- /dev/null +++ b/apps/web/components/Objects/MiniInfoTooltip.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { motion } from 'framer-motion'; + +interface MiniInfoTooltipProps { + icon?: React.ReactNode; + message: string; + onClose: () => void; + iconColor?: string; + iconSize?: number; + width?: string; +} + +export default function MiniInfoTooltip({ + icon, + message, + onClose, + iconColor = 'text-teal-600', + iconSize = 20, + width = 'w-48' +}: MiniInfoTooltipProps) { + return ( + +
+ {icon && ( +
+ {icon} +
+ )} +

{message}

+
+
+ +
+ ); +} \ No newline at end of file diff --git a/apps/web/components/Pages/Activity/ActivityBreadcrumbs.tsx b/apps/web/components/Pages/Activity/ActivityBreadcrumbs.tsx new file mode 100644 index 00000000..8cc07c7d --- /dev/null +++ b/apps/web/components/Pages/Activity/ActivityBreadcrumbs.tsx @@ -0,0 +1,35 @@ +import { Book, ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { getUriWithOrg } from '@services/config/config' +import React from 'react' + +interface ActivityBreadcrumbsProps { + course: any + activity: any + orgslug: string +} + +export default function ActivityBreadcrumbs({ course, activity, orgslug }: ActivityBreadcrumbsProps) { + const cleanCourseUuid = course.course_uuid?.replace('course_', '') + + return ( +
+
+
+ + + Courses + +
+ + + {course.name} + + +
+ {activity.name} +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx b/apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx index 5c186634..9e40af9c 100644 --- a/apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx +++ b/apps/web/components/Pages/Activity/ActivityChapterDropdown.tsx @@ -68,15 +68,16 @@ export default function ActivityChapterDropdown(props: ActivityChapterDropdownPr
{isOpen && ( -
+

Course Content

+ + + {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..6d28b265 100644 --- a/apps/web/components/Pages/Courses/ActivityIndicators.tsx +++ b/apps/web/components/Pages/Courses/ActivityIndicators.tsx @@ -1,20 +1,109 @@ +'use client' +import { BookOpenCheck, Check, FileText, Layers, Video, ChevronLeft, ChevronRight } from 'lucide-react' +import React, { useMemo, memo, useState } 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' +import { useRouter } from 'next/navigation' interface Props { course: any orgslug: string course_uuid: string - current_activity?: any + current_activity?: string + enableNavigation?: boolean } +// 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