Merge pull request #476 from learnhouse/feat/more-ux-updates

More ux updates
This commit is contained in:
Badr B. 2025-05-08 12:03:13 +02:00 committed by GitHub
commit bf94855d0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1147 additions and 413 deletions

View file

@ -60,7 +60,11 @@ async def authorization_verify_if_user_is_author(
element_uuid: str,
db_session: Session,
):
if action == "update" or "delete" or "read":
# For create action, we don't need to check existing resource
if action == "create":
return True # Allow creation if user is authenticated
if action in ["update", "delete", "read"]:
statement = select(ResourceAuthor).where(
ResourceAuthor.resource_uuid == element_uuid
)
@ -79,6 +83,7 @@ async def authorization_verify_if_user_is_author(
return False
else:
return False
return False
# Tested and working
@ -101,17 +106,17 @@ async def authorization_verify_based_on_roles(
user_roles_in_organization_and_standard_roles = db_session.exec(statement).all()
# Find in roles list if there is a role that matches users action for this type of element
# Check all roles until we find one that grants the permission
for role in user_roles_in_organization_and_standard_roles:
role = Role.model_validate(role)
if role.rights:
rights = role.rights
if rights[element_type][f"action_{action}"] is True:
element_rights = getattr(rights, element_type, None)
if element_rights and getattr(element_rights, f"action_{action}", False):
return True
else:
return False
else:
return False
# If we get here, no role granted the permission
return False
async def authorization_verify_based_on_org_admin_status(
@ -133,13 +138,13 @@ async def authorization_verify_based_on_org_admin_status(
user_roles_in_organization_and_standard_roles = db_session.exec(statement).all()
# Find in roles list if there is a role that matches users action for this type of element
# Check if user has admin role (role_id 1 or 2) in any organization
for role in user_roles_in_organization_and_standard_roles:
role = Role.model_validate(role)
if role.id == 1 or role.id == 2:
if role.id in [1, 2]: # Assuming 1 and 2 are admin role IDs
return True
else:
return False
return False
# Tested and working

View file

@ -3,7 +3,7 @@ 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 } from 'lucide-react'
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'
@ -16,7 +16,7 @@ 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 } from 'react'
import React, { useEffect, useRef } 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'
@ -33,6 +33,7 @@ import ActivityNavigation from '@components/Pages/Activity/ActivityNavigation'
import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterDropdown'
import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar'
import CourseEndView from '@components/Pages/Activity/CourseEndView'
import { motion, AnimatePresence } from 'framer-motion'
interface ActivityClientProps {
activityid: string
@ -42,6 +43,55 @@ interface ActivityClientProps {
course: any
}
interface ActivityActionsProps {
activity: any
activityid: string
course: any
orgslug: string
assignment: any
showNavigation?: boolean
}
function ActivityActions({ activity, activityid, course, orgslug, assignment, showNavigation = true }: ActivityActionsProps) {
const session = useLHSession() as any;
const { contributorStatus } = useContributorStatus(course.course_uuid);
return (
<div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
<>
<MarkStatus
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentTools
assignment={assignment}
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</AssignmentSubmissionProvider>
</>
)}
{showNavigation && (
<NextActivityButton course={course} currentActivityId={activity.id} orgslug={orgslug} />
)}
</AuthenticatedClientElement>
)}
</div>
);
}
function ActivityClient(props: ActivityClientProps) {
const activityid = props.activityid
const courseuuid = props.courseuuid
@ -55,8 +105,69 @@ function ActivityClient(props: ActivityClientProps) {
const [bgColor, setBgColor] = React.useState('bg-white')
const [assignment, setAssignment] = React.useState(null) as any;
const [markStatusButtonActive, setMarkStatusButtonActive] = React.useState(false);
const [isFocusMode, setIsFocusMode] = React.useState(false);
const isInitialRender = useRef(true);
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();
// 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;
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
};
// Initialize focus mode from localStorage
React.useEffect(() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('globalFocusMode');
setIsFocusMode(saved === 'true');
}
}, []);
// Save focus mode to localStorage
React.useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('globalFocusMode', isFocusMode.toString());
// Dispatch custom event for focus mode change
window.dispatchEvent(new CustomEvent('focusModeChange', {
detail: { isFocusMode }
}));
isInitialRender.current = false;
}
}, [isFocusMode]);
function getChapterNameByActivityId(course: any, activity_id: any) {
for (let i = 0; i < course.chapters.length; i++) {
@ -78,43 +189,72 @@ function ActivityClient(props: ActivityClientProps) {
useEffect(() => {
if (activity.activity_type == 'TYPE_DYNAMIC') {
setBgColor('bg-white nice-shadow');
setBgColor(isFocusMode ? 'bg-white' : 'bg-white nice-shadow');
}
else if (activity.activity_type == 'TYPE_ASSIGNMENT') {
setMarkStatusButtonActive(false);
setBgColor('bg-white nice-shadow');
setBgColor(isFocusMode ? 'bg-white' : 'bg-white nice-shadow');
getAssignmentUI();
}
else {
setBgColor('bg-zinc-950');
setBgColor(isFocusMode ? 'bg-zinc-950' : 'bg-zinc-950 nice-shadow');
}
}
, [activity, pathname])
, [activity, pathname, isFocusMode])
return (
<>
<CourseProvider courseuuid={course?.course_uuid}>
<AIChatBotProvider>
<GeneralWrapperStyled>
{activityid === 'end' ? (
<CourseEndView
courseName={course.name}
orgslug={orgslug}
courseUuid={course.course_uuid}
thumbnailImage={course.thumbnail_image}
/>
) : (
<div className="space-y-4 pt-0">
<div className="pt-2">
<div className="space-y-4 pb-4 activity-info-section">
<div className="flex justify-between items-center">
<div className="flex space-x-6">
{isFocusMode ? (
<AnimatePresence>
<motion.div
initial={isInitialRender.current ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 bg-white z-50"
>
{/* Focus Mode Top Bar */}
<motion.div
initial={isInitialRender.current ? false : { y: -100 }}
animate={{ y: 0 }}
exit={{ y: -100 }}
transition={{ duration: 0.3 }}
className="fixed top-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-b border-gray-100"
>
<div className="container mx-auto px-4 py-2">
<div className="flex items-center justify-between h-14">
<div className="flex items-center space-x-2">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => setIsFocusMode(false)}
className="bg-white nice-shadow p-2 rounded-full cursor-pointer hover:bg-gray-50"
title="Exit focus mode"
>
<Minimize2 size={16} className="text-gray-700" />
</motion.button>
<ActivityChapterDropdown
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
</div>
{/* Center Course Info */}
<motion.div
initial={isInitialRender.current ? false : { opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex items-center space-x-4"
>
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
className="w-[60px] h-[34px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
@ -125,147 +265,363 @@ function ActivityClient(props: ActivityClientProps) {
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
<p className="font-bold text-gray-700 text-sm">Course </p>
<h1 className="font-bold text-gray-950 text-lg first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
</motion.div>
{/* Progress Indicator */}
<motion.div
initial={isInitialRender.current ? false : { opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="flex items-center space-x-2"
>
<div className="relative w-8 h-8">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="16"
cy="16"
r="14"
stroke="#e5e7eb"
strokeWidth="3"
fill="none"
/>
<circle
cx="16"
cy="16"
r="14"
stroke="#10b981"
strokeWidth="3"
fill="none"
strokeLinecap="round"
strokeDasharray={2 * Math.PI * 14}
strokeDashoffset={2 * Math.PI * 14 * (1 - (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))}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-bold text-gray-800">
{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)}%
</span>
</div>
</div>
<div className="text-xs text-gray-600">
{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}
</div>
</motion.div>
</div>
</div>
</motion.div>
<ActivityIndicators
course_uuid={courseuuid}
current_activity={activityid}
orgslug={orgslug}
course={course}
/>
{/* Focus Mode Content */}
<div className="pt-16 pb-20 h-full overflow-auto">
<div className="container mx-auto px-4">
{activity && activity.published == true && (
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<motion.div
initial={isInitialRender.current ? false : { scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3 }}
className={`p-7 rounded-lg ${bgColor} mt-4`}
>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
)}
</div>
)}
</div>
</motion.div>
)}
</>
)}
</div>
</div>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<ActivityChapterDropdown
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">
Chapter : {getChapterNameByActivityId(course, activity.id)}
</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{activity.name}
</h1>
{/* Focus Mode Bottom Bar */}
{activity && activity.published == true && activity.content.paid_access != false && (
<motion.div
initial={isInitialRender.current ? false : { y: 100 }}
animate={{ y: 0 }}
exit={{ y: 100 }}
transition={{ duration: 0.3 }}
className="fixed bottom-0 left-0 right-0 z-50 bg-white/90 backdrop-blur-xl border-t border-gray-100"
>
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-2">
<button
onClick={() => navigateToActivity(prevActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
prevActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!prevActivity}
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
>
<ChevronLeft size={20} className="text-gray-800 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-xs text-gray-500">Previous</span>
<span className="text-sm capitalize font-semibold text-left">
{prevActivity ? prevActivity.name : 'No previous activity'}
</span>
</div>
</button>
</div>
<div className="flex items-center space-x-2">
<ActivityActions
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
assignment={assignment}
showNavigation={false}
/>
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
nextActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>
<div className="flex flex-col items-end">
<span className="text-xs text-gray-500">Next</span>
<span className="text-sm capitalize font-semibold text-right">
{nextActivity ? nextActivity.name : 'No next activity'}
</span>
</div>
<ChevronRight size={20} className="text-gray-800 shrink-0" />
</button>
</div>
</div>
<div className="flex space-x-2 items-center">
</div>
</motion.div>
)}
</motion.div>
</AnimatePresence>
) : (
<GeneralWrapperStyled>
{/* Original non-focus mode UI */}
{activityid === 'end' ? (
<CourseEndView
courseName={course.name}
orgslug={orgslug}
courseUuid={course.course_uuid}
thumbnailImage={course.thumbnail_image}
/>
) : (
<div className="space-y-4 pt-0">
<div className="pt-2">
<div className="space-y-4 pb-4 activity-info-section">
<div className="flex justify-between items-center">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
<>
<AIActivityAsk activity={activity} />
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute to Activity</span>
</Link>
)}
<MoreVertical size={17} className="text-gray-300" />
<MarkStatus
activity={activity}
activityid={activityid}
{ (
<div className="flex space-x-2">
<PreviousActivityButton
course={course}
currentActivityId={activity.id}
orgslug={orgslug}
/>
</>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<>
<MoreVertical size={17} className="text-gray-300 " />
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentTools
assignment={assignment}
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</AssignmentSubmissionProvider>
</>
<NextActivityButton
course={course}
currentActivityId={activity.id}
orgslug={orgslug}
/>
</div>
)}
</AuthenticatedClientElement>
)}
</div>
</div>
</div>
{activity && activity.published == false && (
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
<div className="text-white">
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)}
{activity && activity.published == true && (
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
)}
</div>
)}
<ActivityIndicators
course_uuid={courseuuid}
current_activity={activityid}
orgslug={orgslug}
course={course}
/>
<div className="flex justify-between items-center w-full">
<div className="flex flex-1/3 items-center space-x-3">
<button
onClick={() => setIsFocusMode(true)}
className="bg-white nice-shadow p-2 rounded-full cursor-pointer hover:bg-gray-50 transition-all duration-200"
title="Enter focus mode"
>
<Maximize2 size={16} className="text-gray-700" />
</button>
<ActivityChapterDropdown
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">
Chapter : {getChapterNameByActivityId(course, activity.id)}
</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{activity.name}
</h1>
</div>
</div>
)}
</>
)}
<div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
<>
<AIActivityAsk activity={activity} />
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute</span>
</Link>
)}
</>
)}
</AuthenticatedClientElement>
)}
</div>
</div>
</div>
{activity && activity.published == false && (
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
<div className="text-white">
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)}
{/* Fixed Activity Secondary Bar */}
{activity && activity.published == true && activity.content.paid_access != false && (
<FixedActivitySecondaryBar
course={course}
currentActivityId={activityid}
orgslug={orgslug}
activity={activity}
/>
)}
{activity && activity.published == true && (
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
)}
</div>
)}
</div>
</div>
)}
</>
)}
<div style={{ height: '100px' }}></div>
{/* Activity Actions below the content box */}
{activity && activity.published == true && activity.content.paid_access != false && (
<div className="flex justify-end mt-4">
<ActivityActions
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
assignment={assignment}
/>
</div>
)}
{/* Fixed Activity Secondary Bar */}
{activity && activity.published == true && activity.content.paid_access != false && (
<FixedActivitySecondaryBar
course={course}
currentActivityId={activityid}
orgslug={orgslug}
activity={activity}
/>
)}
<div style={{ height: '100px' }}></div>
</div>
</div>
</div>
)}
</GeneralWrapperStyled>
)}
</GeneralWrapperStyled>
)}
</AIChatBotProvider>
</CourseProvider>
</>
@ -415,7 +771,6 @@ export function MarkStatus(props: {
status="warning"
/>
</ToolTip>
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
</div>
) : (
<div className="flex items-center space-x-2">
@ -437,7 +792,6 @@ export function MarkStatus(props: {
)}{' '}
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</i>}
</div>
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
</div>
)}
</>
@ -483,15 +837,66 @@ function NextActivityButton({ course, currentActivityId, orgslug }: { course: an
};
return (
<ToolTip content={`Next: ${nextActivity.name}`} side="top">
<div
onClick={navigateToActivity}
className="bg-gray-300 rounded-full px-5 nice-shadow flex items-center space-x-2 p-2.5 text-gray-600 hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
{!isMobile && <span className="text-xs font-bold">Next</span>}
<ChevronRight size={17} />
</div>
</ToolTip>
<div
onClick={navigateToActivity}
className="bg-white rounded-full px-5 nice-shadow flex items-center space-x-1 p-2.5 text-gray-600 hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<span className="text-xs font-bold text-gray-500">Next</span>
<EllipsisVertical className='text-gray-400' size={13} />
<span className="text-sm font-semibold truncate max-w-[200px]">{nextActivity.name}</span>
<ChevronRight size={17} />
</div>
);
}
function PreviousActivityButton({ course, currentActivityId, orgslug }: { course: any, currentActivityId: string, orgslug: string }) {
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const findPreviousActivity = () => {
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 (activity.id === currentActivityId) {
currentIndex = allActivities.length - 1;
}
});
});
// Get previous activity
return currentIndex > 0 ? allActivities[currentIndex - 1] : null;
};
const previousActivity = findPreviousActivity();
if (!previousActivity) return null;
const navigateToActivity = () => {
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}/activity/${previousActivity.cleanUuid}`);
};
return (
<div
onClick={navigateToActivity}
className="bg-white rounded-full px-5 nice-shadow flex items-center space-x-1 p-2.5 text-gray-600 hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<ChevronLeft size={17} />
<span className="text-xs font-bold text-gray-500">Previous</span>
<EllipsisVertical className='text-gray-400' size={13} />
<span className="text-sm font-semibold truncate max-w-[200px]">{previousActivity.name}</span>
</div>
);
}

View file

@ -60,6 +60,16 @@ const CourseClient = (props: any) => {
useEffect(() => {
getLearningTags()
// Collapse chapters by default if more than 5 activities in total
if (course?.chapters) {
const totalActivities = course.chapters.reduce((sum: number, chapter: any) => sum + (chapter.activities?.length || 0), 0)
const defaultExpanded: {[key: string]: boolean} = {}
course.chapters.forEach((chapter: any) => {
defaultExpanded[chapter.chapter_uuid] = totalActivities <= 5
})
setExpandedChapters(defaultExpanded)
}
}, [org, course])
const getActivityTypeLabel = (activityType: string) => {
@ -117,191 +127,52 @@ const CourseClient = (props: any) => {
) : (
<>
<GeneralWrapperStyled>
<div className="pb-3 flex flex-col md:flex-row justify-between items-start md:items-center">
<div className="pb-2 pt-5 flex flex-col md:flex-row justify-between items-start md:items-center">
<div>
<p className="text-md font-bold text-gray-400 pb-2">Course</p>
<h1 className="text-3xl md:text-3xl -mt-3 font-bold">{course.name}</h1>
</div>
</div>
{props.course?.thumbnail_image && org ? (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[200px] md:h-[400px] bg-cover bg-center mb-4"
style={{
backgroundImage: `url(${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course?.course_uuid,
course?.thumbnail_image
)})`,
}}
></div>
) : (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-auto h-[400px] bg-cover bg-center mb-4"
style={{
backgroundImage: `url('../empty_thumbnail.png')`,
backgroundSize: 'auto',
}}
></div>
)}
<ActivityIndicators
course_uuid={props.course.course_uuid}
orgslug={orgslug}
course={course}
/>
<div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10">
<div className="course_metadata_left w-full md:basis-3/4 space-y-2">
<h2 className="py-3 text-2xl font-bold">About</h2>
<div className="">
<p className="py-5 whitespace-pre-wrap">{course.about}</p>
</div>
{learnings.length > 0 && learnings[0]?.text !== 'null' && (
<div>
<h2 className="py-3 text-2xl font-bold">
What you will learn
</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
{learnings.map((learning: any) => {
// Handle both new format (object with text and emoji) and legacy format (string)
const learningText = typeof learning === 'string' ? learning : learning.text
const learningEmoji = typeof learning === 'string' ? null : learning.emoji
const learningId = typeof learning === 'string' ? learning : learning.id || learning.text
if (!learningText) return null
return (
<div
key={learningId}
className="flex space-x-2 items-center font-semibold text-gray-500"
>
<div className="px-2 py-2 rounded-full">
{learningEmoji ? (
<span>{learningEmoji}</span>
) : (
<Check className="text-gray-400" size={15} />
)}
</div>
<p>{learningText}</p>
{learning.link && (
<a
href={learning.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline text-sm"
>
<span className="sr-only">Link to {learningText}</span>
<ArrowRight size={14} />
</a>
)}
</div>
)
})}
</div>
</div>
<div className="flex flex-col md:flex-row gap-8 pt-2">
<div className="w-full md:w-3/4 space-y-4">
{props.course?.thumbnail_image && org ? (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[200px] md:h-[400px] bg-cover bg-center"
style={{
backgroundImage: `url(${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course?.course_uuid,
course?.thumbnail_image
)})`,
}}
></div>
) : (
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-lg shadow-xl relative w-full h-[400px] bg-cover bg-center"
style={{
backgroundImage: `url('../empty_thumbnail.png')`,
backgroundSize: 'auto',
}}
></div>
)}
<h2 className="py-3 text-xl md:text-2xl font-bold">Course Lessons</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
{course.chapters.map((chapter: any) => {
const isExpanded = expandedChapters[chapter.chapter_uuid] ?? true; // Default to expanded
return (
<div key={chapter.chapter_uuid || `chapter-${chapter.name}`} className="">
<div
className="flex text-lg py-4 px-4 outline outline-1 outline-neutral-200/40 font-bold bg-neutral-50 text-neutral-600 items-center cursor-pointer hover:bg-neutral-100 transition-colors"
onClick={() => setExpandedChapters(prev => ({
...prev,
[chapter.chapter_uuid]: !isExpanded
}))}
>
<h3 className="grow mr-3 break-words">{chapter.name}</h3>
<div className="flex items-center space-x-3">
<p className="text-sm font-normal text-neutral-400 px-3 py-[2px] outline-1 outline outline-neutral-200 rounded-full whitespace-nowrap shrink-0">
{chapter.activities.length} Activities
</p>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className={`py-3 transition-all duration-200 ${isExpanded ? 'block' : 'hidden'}`}>
<div className="py-3">
{chapter.activities.map((activity: any) => {
return (
<div key={activity.activity_uuid} className="activity-container">
<div className="group hover:bg-neutral-50 transition-colors px-4 py-3">
<div className="flex space-x-3 items-center">
<div className="flex items-center">
{isActivityDone(activity) ? (
<div className="relative cursor-pointer">
<Square size={18} className="stroke-[2] text-teal-600" />
<Check size={18} className="stroke-[2.5] text-teal-600 absolute top-0 left-0" />
</div>
) : (
<div className="text-neutral-300 cursor-pointer">
<Square size={18} className="stroke-[2]" />
</div>
)}
</div>
<Link
className="flex flex-col grow"
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace(
'activity_',
''
)}`
}
rel="noopener noreferrer"
prefetch={false}
>
<div className="flex items-center space-x-2 w-full">
<p className="font-semibold text-neutral-600 group-hover:text-neutral-800 transition-colors">{activity.name}</p>
{isActivityCurrent(activity) && (
<div className="flex items-center space-x-1 text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full text-xs font-semibold animate-pulse">
<span>Current</span>
</div>
)}
</div>
<div className="flex items-center space-x-1.5 mt-1 text-neutral-400">
{activity.activity_type === 'TYPE_DYNAMIC' && (
<StickyNote size={11} />
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<Video size={11} />
)}
{activity.activity_type === 'TYPE_DOCUMENT' && (
<File size={11} />
)}
{activity.activity_type === 'TYPE_ASSIGNMENT' && (
<Backpack size={11} />
)}
<span className="text-xs font-medium">{getActivityTypeLabel(activity.activity_type)}</span>
</div>
</Link>
<div className="text-neutral-300 group-hover:text-neutral-400 transition-colors cursor-pointer">
<ArrowRight size={16} />
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
</div>
)
})}
{course?.trail?.runs?.find((run: any) => run.course_id == course.id) && (
<ActivityIndicators
course_uuid={props.course.course_uuid}
orgslug={orgslug}
course={course}
/>
)}
<div className="course_metadata_left space-y-2">
<div className="">
<p className="py-5 whitespace-pre-wrap">{course.about}</p>
</div>
</div>
</div>
<div className='course_metadata_right basis-1/4 space-y-4'>
<div className='course_metadata_right w-full md:w-1/4 space-y-4'>
{/* Actions Box */}
<CoursesActions courseuuid={courseuuid} orgslug={orgslug} course={course} />
@ -313,6 +184,145 @@ const CourseClient = (props: any) => {
</div>
</div>
</div>
{learnings.length > 0 && learnings[0]?.text !== 'null' && (
<div className="w-full">
<h2 className="py-5 text-xl md:text-2xl font-bold">What you will learn</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden px-5 py-5 space-y-2">
{learnings.map((learning: any) => {
// Handle both new format (object with text and emoji) and legacy format (string)
const learningText = typeof learning === 'string' ? learning : learning.text
const learningEmoji = typeof learning === 'string' ? null : learning.emoji
const learningId = typeof learning === 'string' ? learning : learning.id || learning.text
if (!learningText) return null
return (
<div
key={learningId}
className="flex space-x-2 items-center font-semibold text-gray-500"
>
<div className="px-2 py-2 rounded-full">
{learningEmoji ? (
<span>{learningEmoji}</span>
) : (
<Check className="text-gray-400" size={15} />
)}
</div>
<p>{learningText}</p>
{learning.link && (
<a
href={learning.link}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline text-sm"
>
<span className="sr-only">Link to {learningText}</span>
<ArrowRight size={14} />
</a>
)}
</div>
)
})}
</div>
</div>
)}
<div className="w-full my-5 mb-10">
<h2 className="py-5 text-xl md:text-2xl font-bold">Course Lessons</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
{course.chapters.map((chapter: any) => {
const isExpanded = expandedChapters[chapter.chapter_uuid] ?? true; // Default to expanded
return (
<div key={chapter.chapter_uuid || `chapter-${chapter.name}`} className="">
<div
className="flex text-lg py-4 px-4 outline outline-1 outline-neutral-200/40 font-bold bg-neutral-50 text-neutral-600 items-center cursor-pointer hover:bg-neutral-100 transition-colors"
onClick={() => setExpandedChapters(prev => ({
...prev,
[chapter.chapter_uuid]: !isExpanded
}))}
>
<h3 className="grow mr-3 break-words">{chapter.name}</h3>
<div className="flex items-center space-x-3">
<p className="text-sm font-normal text-neutral-400 px-3 py-[2px] outline-1 outline outline-neutral-200 rounded-full whitespace-nowrap shrink-0">
{chapter.activities.length} Activities
</p>
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<div className={`transition-all duration-200 ${isExpanded ? 'block' : 'hidden'}`}>
<div className="">
{chapter.activities.map((activity: any) => {
return (
<Link
key={activity.activity_uuid}
href={
getUriWithOrg(orgslug, '') +
`/course/${courseuuid}/activity/${activity.activity_uuid.replace('activity_', '')}`
}
rel="noopener noreferrer"
prefetch={false}
className="block group activity-container transition-all duration-200 px-4 py-4"
>
<div className="flex space-x-3 items-center">
<div className="flex items-center">
{isActivityDone(activity) ? (
<div className="relative cursor-pointer">
<Square size={16} className="stroke-[2] text-teal-600" />
<Check size={16} className="stroke-[2.5] text-teal-600 absolute top-0 left-0" />
</div>
) : (
<div className="text-neutral-300 cursor-pointer">
<Square size={16} className="stroke-[2]" />
</div>
)}
</div>
<div className="flex flex-col grow">
<div className="flex items-center space-x-2 w-full">
<p className="font-semibold text-neutral-600 group-hover:text-neutral-800 transition-colors">{activity.name}</p>
{isActivityCurrent(activity) && (
<div className="flex items-center space-x-1 text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full text-xs font-semibold animate-pulse">
<span>Current</span>
</div>
)}
</div>
<div className="flex items-center space-x-1.5 mt-0.5 text-neutral-400">
{activity.activity_type === 'TYPE_DYNAMIC' && (
<StickyNote size={10} />
)}
{activity.activity_type === 'TYPE_VIDEO' && (
<Video size={10} />
)}
{activity.activity_type === 'TYPE_DOCUMENT' && (
<File size={10} />
)}
{activity.activity_type === 'TYPE_ASSIGNMENT' && (
<Backpack size={10} />
)}
<span className="text-xs font-medium">{getActivityTypeLabel(activity.activity_type)}</span>
</div>
</div>
<div className="text-neutral-300 group-hover:text-neutral-400 transition-colors cursor-pointer">
<ArrowRight size={14} />
</div>
</div>
</Link>
)
})}
</div>
</div>
</div>
)
})}
</div>
</div>
</GeneralWrapperStyled>
{isMobile && (

View file

@ -17,6 +17,9 @@ export default Node.create({
size: {
width: 300,
},
alignment: {
default: 'center',
},
}
},

View file

@ -1,7 +1,7 @@
import { NodeViewWrapper } from '@tiptap/react'
import React, { useEffect } from 'react'
import { Resizable } from 're-resizable'
import { AlertTriangle, Image, Download } from 'lucide-react'
import { AlertTriangle, Image, Download, AlignLeft, AlignCenter, AlignRight } from 'lucide-react'
import { uploadNewImageFile } from '../../../../../services/blocks/Image/images'
import { getActivityBlockMediaDirectory } from '@services/media/media'
import { useOrg } from '@components/Contexts/OrgContext'
@ -29,10 +29,12 @@ function ImageBlockComponent(props: any) {
const [imageSize, setImageSize] = React.useState({
width: props.node.attrs.size ? props.node.attrs.size.width : 300,
})
const [alignment, setAlignment] = React.useState(props.node.attrs.alignment || 'center')
const fileId = blockObject
? `${blockObject.content.file_id}.${blockObject.content.file_format}`
: null
const handleImageChange = (event: React.ChangeEvent<any>) => {
setImage(event.target.files[0])
}
@ -49,6 +51,7 @@ function ImageBlockComponent(props: any) {
props.updateAttributes({
blockObject: object,
size: imageSize,
alignment: alignment,
})
}
@ -75,8 +78,26 @@ function ImageBlockComponent(props: any) {
document.body.removeChild(link);
};
const handleAlignmentChange = (newAlignment: string) => {
setAlignment(newAlignment);
props.updateAttributes({
alignment: newAlignment,
});
};
useEffect(() => {}, [course, org])
const getAlignmentClass = () => {
switch (alignment) {
case 'left':
return 'justify-start';
case 'right':
return 'justify-end';
default:
return 'justify-center';
}
};
return (
<NodeViewWrapper className="block-image w-full">
<FileUploadBlock isEditable={isEditable} isLoading={isLoading} isEmpty={!blockObject} Icon={Image}>
@ -85,7 +106,7 @@ function ImageBlockComponent(props: any) {
</FileUploadBlock>
{blockObject && isEditable && (
<div className="w-full flex justify-center">
<div className={`w-full flex ${getAlignmentClass()}`}>
<Resizable
defaultSize={{ width: imageSize.width, height: '100%' }}
handleStyles={{
@ -123,25 +144,50 @@ function ImageBlockComponent(props: any) {
})
}}
>
<img
src={`${getActivityBlockMediaDirectory(
org?.org_uuid,
course?.courseStructure.course_uuid,
props.extension.options.activity.activity_uuid,
blockObject.block_uuid,
blockObject ? fileId : ' ',
'imageBlock'
)}`}
alt=""
className="rounded-lg shadow-sm max-w-full h-auto"
style={{ width: '100%' }}
/>
<div className="relative">
<img
src={`${getActivityBlockMediaDirectory(
org?.org_uuid,
course?.courseStructure.course_uuid,
props.extension.options.activity.activity_uuid,
blockObject.block_uuid,
blockObject ? fileId : ' ',
'imageBlock'
)}`}
alt=""
className="rounded-lg shadow-sm max-w-full h-auto"
style={{ width: '100%' }}
/>
<div className="absolute top-2 right-2 flex items-center gap-1.5 bg-white bg-opacity-90 backdrop-blur-xs rounded-lg p-1 shadow-xs transition-opacity opacity-70 hover:opacity-100">
<button
onClick={() => handleAlignmentChange('left')}
className={`p-1.5 rounded-md hover:bg-gray-100 text-gray-600 ${alignment === 'left' ? 'bg-gray-100' : ''}`}
title="Align left"
>
<AlignLeft size={16} />
</button>
<button
onClick={() => handleAlignmentChange('center')}
className={`p-1.5 rounded-md hover:bg-gray-100 text-gray-600 ${alignment === 'center' ? 'bg-gray-100' : ''}`}
title="Center align"
>
<AlignCenter size={16} />
</button>
<button
onClick={() => handleAlignmentChange('right')}
className={`p-1.5 rounded-md hover:bg-gray-100 text-gray-600 ${alignment === 'right' ? 'bg-gray-100' : ''}`}
title="Align right"
>
<AlignRight size={16} />
</button>
</div>
</div>
</Resizable>
</div>
)}
{blockObject && !isEditable && (
<div className="w-full flex justify-center">
<div className={`w-full flex ${getAlignmentClass()}`}>
<div className="relative">
<img
src={`${getActivityBlockMediaDirectory(

View file

@ -1,5 +1,5 @@
'use client'
import React from 'react'
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { Search } from 'lucide-react'
import { getUriWithOrg } from '@services/config/config'
@ -9,6 +9,7 @@ import { getOrgLogoMediaDirectory } from '@services/media/media'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import { SearchBar } from '@components/Objects/Search/SearchBar'
import { usePathname } from 'next/navigation'
export const OrgMenu = (props: any) => {
const orgslug = props.orgslug
@ -17,6 +18,41 @@ export const OrgMenu = (props: any) => {
const [feedbackModal, setFeedbackModal] = React.useState(false)
const org = useOrg() as any;
const [isMenuOpen, setIsMenuOpen] = React.useState(false)
const [isFocusMode, setIsFocusMode] = useState(false)
const pathname = usePathname()
useEffect(() => {
// Only check focus mode if we're in an activity page
if (typeof window !== 'undefined' && pathname?.includes('/activity/')) {
const saved = localStorage.getItem('globalFocusMode');
setIsFocusMode(saved === 'true');
} else {
setIsFocusMode(false);
}
// Add storage event listener for cross-window changes
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'globalFocusMode' && pathname?.includes('/activity/')) {
setIsFocusMode(e.newValue === 'true');
}
};
// Add custom event listener for same-window changes
const handleFocusModeChange = (e: CustomEvent) => {
if (pathname?.includes('/activity/')) {
setIsFocusMode(e.detail.isFocusMode);
}
};
window.addEventListener('storage', handleStorageChange);
window.addEventListener('focusModeChange', handleFocusModeChange as EventListener);
// Cleanup
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('focusModeChange', handleFocusModeChange as EventListener);
};
}, [pathname]);
function closeFeedbackModal() {
setFeedbackModal(false)
@ -26,6 +62,11 @@ export const OrgMenu = (props: any) => {
setIsMenuOpen(!isMenuOpen)
}
// Only hide menu if we're in an activity page and focus mode is enabled
if (pathname?.includes('/activity/') && isFocusMode) {
return null;
}
return (
<>
<div className="backdrop-blur-lg h-[60px] blur-3xl -z-10"></div>

View file

@ -160,11 +160,7 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200 ${
nextActivity
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'text-gray-300 cursor-not-allowed'
}`}
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>

View file

@ -5,6 +5,11 @@ import toast from 'react-hot-toast';
export type ContributorStatus = 'NONE' | 'PENDING' | 'ACTIVE' | 'INACTIVE';
interface Contributor {
user_id: string;
authorship_status: ContributorStatus;
}
export function useContributorStatus(courseUuid: string) {
const session = useLHSession() as any;
const [contributorStatus, setContributorStatus] = useState<ContributorStatus>('NONE');
@ -22,9 +27,9 @@ export function useContributorStatus(courseUuid: string) {
session.data?.tokens?.access_token
);
if (response && response.data) {
if (response && response.data && Array.isArray(response.data)) {
const currentUser = response.data.find(
(contributor: any) => contributor.user_id === session.data.user.id
(contributor: Contributor) => contributor.user_id === session.data.user.id
);
if (currentUser) {
@ -32,10 +37,13 @@ export function useContributorStatus(courseUuid: string) {
} else {
setContributorStatus('NONE');
}
} else {
setContributorStatus('NONE');
}
} catch (error) {
console.error('Failed to check contributor status:', error);
toast.error('Failed to check contributor status');
setContributorStatus('NONE');
} finally {
setIsLoading(false);
}

View file

@ -63,7 +63,7 @@
"katex": "^0.16.21",
"lowlight": "^3.3.0",
"lucide-react": "^0.453.0",
"next": "15.2.4",
"next": "15.3.1",
"next-auth": "^4.24.11",
"nextjs-toploader": "^1.6.12",
"prosemirror-state": "^1.4.3",

316
apps/web/pnpm-lock.yaml generated
View file

@ -169,14 +169,14 @@ importers:
specifier: ^0.453.0
version: 0.453.0(react@19.0.0)
next:
specifier: 15.2.4
version: 15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.3.1
version: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 4.24.11(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nextjs-toploader:
specifier: ^1.6.12
version: 1.6.12(next@15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 1.6.12(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
prosemirror-state:
specifier: ^1.4.3
version: 1.4.3
@ -412,158 +412,268 @@ packages:
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-arm64@0.34.1':
resolution: {integrity: sha512-pn44xgBtgpEbZsu+lWf2KNb6OAf70X68k+yk69Ic2Xz11zHR/w24/U49XT7AeRwJ0Px+mhALhU5LPci1Aymk7A==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.1':
resolution: {integrity: sha512-VfuYgG2r8BpYiOUN+BfYeFo69nP/MIwAtSJ7/Zpxc5QF3KS22z8Pvg3FkrSFJBPNQ7mmcUcYQFBmEQp7eu1F8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.1.0':
resolution: {integrity: sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.1.0':
resolution: {integrity: sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm64@1.1.0':
resolution: {integrity: sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-arm@1.1.0':
resolution: {integrity: sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-ppc64@1.1.0':
resolution: {integrity: sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.1.0':
resolution: {integrity: sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linux-x64@1.1.0':
resolution: {integrity: sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
resolution: {integrity: sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
resolution: {integrity: sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm64@0.34.1':
resolution: {integrity: sha512-kX2c+vbvaXC6vly1RDf/IWNXxrlxLNpBVWkdpRq5Ka7OOKj6nr66etKy2IENf6FtOgklkg9ZdGpEu9kwdlcwOQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-arm@0.34.1':
resolution: {integrity: sha512-anKiszvACti2sGy9CirTlNyk7BjjZPiML1jt2ZkTdcvpLU1YH6CXwRAZCA2UmRXnhiIftXQ7+Oh62Ji25W72jA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-s390x@0.34.1':
resolution: {integrity: sha512-7s0KX2tI9mZI2buRipKIw2X1ufdTeaRgwmRabt5bi9chYfhur+/C1OXg3TKg/eag1W+6CCWLVmSauV1owmRPxA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linux-x64@0.34.1':
resolution: {integrity: sha512-wExv7SH9nmoBW3Wr2gvQopX1k8q2g5V5Iag8Zk6AVENsjwd+3adjwxtp3Dcu2QhOXr8W9NusBU6XcQUohBZ5MA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.34.1':
resolution: {integrity: sha512-DfvyxzHxw4WGdPiTF0SOHnm11Xv4aQexvqhRDAoD00MzHekAj9a/jADXeXYCDFH/DzYruwHbXU7uz+H+nWmSOQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.34.1':
resolution: {integrity: sha512-pax/kTR407vNb9qaSIiWVnQplPcGU8LRIJpDT5o8PdAx5aAA7AS3X9PS8Isw1/WfqgQorPotjrZL3Pqh6C5EBg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-wasm32@0.34.1':
resolution: {integrity: sha512-YDybQnYrLQfEpzGOQe7OKcyLUCML4YOXl428gOOzBgN6Gw0rv8dpsJ7PqTHxBnXnwXr8S1mYFSLSa727tpz0xg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-ia32@0.34.1':
resolution: {integrity: sha512-WKf/NAZITnonBf3U1LfdjoMgNO5JYRSlhovhRhMxXVdvWYveM4kM3L8m35onYIdh75cOMCo1BexgVQcCDzyoWw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@img/sharp-win32-x64@0.34.1':
resolution: {integrity: sha512-hw1iIAHpNE8q3uMIRCgGOeDoz9KtFNarFLQclLxr/LK1VBkj8nby18RjFvr6aP7USRYAjTZW6yisnBWMX571Tw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@napi-rs/wasm-runtime@0.2.8':
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
'@next/env@15.2.4':
resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==}
'@next/env@15.3.1':
resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==}
'@next/eslint-plugin-next@15.2.1':
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
'@next/swc-darwin-arm64@15.2.4':
resolution: {integrity: sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==}
'@next/swc-darwin-arm64@15.3.1':
resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.2.4':
resolution: {integrity: sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==}
'@next/swc-darwin-x64@15.3.1':
resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.4':
resolution: {integrity: sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==}
'@next/swc-linux-arm64-gnu@15.3.1':
resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.2.4':
resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
'@next/swc-linux-arm64-musl@15.3.1':
resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.2.4':
resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
'@next/swc-linux-x64-gnu@15.3.1':
resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.2.4':
resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
'@next/swc-linux-x64-musl@15.3.1':
resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.2.4':
resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
'@next/swc-win32-arm64-msvc@15.3.1':
resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.2.4':
resolution: {integrity: sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==}
'@next/swc-win32-x64-msvc@15.3.1':
resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -2768,8 +2878,8 @@ packages:
nodemailer:
optional: true
next@15.2.4:
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
next@15.3.1:
resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -3216,6 +3326,10 @@ packages:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
sharp@0.34.1:
resolution: {integrity: sha512-1j0w61+eVxu7DawFJtnfYcvSv6qPFvfTaqzTQ2BLknVhHTwGS8sc63ZBF4rzkWMBVKybo4S5OBtDdZahh2A1xg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -3657,76 +3771,154 @@ snapshots:
'@img/sharp-libvips-darwin-arm64': 1.0.4
optional: true
'@img/sharp-darwin-arm64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.1.0
optional: true
'@img/sharp-darwin-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
optional: true
'@img/sharp-darwin-x64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.1.0
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-arm64@1.1.0':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.1.0':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.1.0':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
optional: true
'@img/sharp-libvips-linux-arm@1.1.0':
optional: true
'@img/sharp-libvips-linux-ppc64@1.1.0':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.1.0':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
optional: true
'@img/sharp-libvips-linux-x64@1.1.0':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.1.0':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.1.0':
optional: true
'@img/sharp-linux-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
optional: true
'@img/sharp-linux-arm64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.1.0
optional: true
'@img/sharp-linux-arm@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
optional: true
'@img/sharp-linux-arm@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.1.0
optional: true
'@img/sharp-linux-s390x@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
optional: true
'@img/sharp-linux-s390x@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.1.0
optional: true
'@img/sharp-linux-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
optional: true
'@img/sharp-linux-x64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.1.0
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.1':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
optional: true
'@img/sharp-wasm32@0.33.5':
dependencies:
'@emnapi/runtime': 1.4.0
optional: true
'@img/sharp-wasm32@0.34.1':
dependencies:
'@emnapi/runtime': 1.4.0
optional: true
'@img/sharp-win32-ia32@0.33.5':
optional: true
'@img/sharp-win32-ia32@0.34.1':
optional: true
'@img/sharp-win32-x64@0.33.5':
optional: true
'@img/sharp-win32-x64@0.34.1':
optional: true
'@napi-rs/wasm-runtime@0.2.8':
dependencies:
'@emnapi/core': 1.4.0
@ -3734,34 +3926,34 @@ snapshots:
'@tybys/wasm-util': 0.9.0
optional: true
'@next/env@15.2.4': {}
'@next/env@15.3.1': {}
'@next/eslint-plugin-next@15.2.1':
dependencies:
fast-glob: 3.3.1
'@next/swc-darwin-arm64@15.2.4':
'@next/swc-darwin-arm64@15.3.1':
optional: true
'@next/swc-darwin-x64@15.2.4':
'@next/swc-darwin-x64@15.3.1':
optional: true
'@next/swc-linux-arm64-gnu@15.2.4':
'@next/swc-linux-arm64-gnu@15.3.1':
optional: true
'@next/swc-linux-arm64-musl@15.2.4':
'@next/swc-linux-arm64-musl@15.3.1':
optional: true
'@next/swc-linux-x64-gnu@15.2.4':
'@next/swc-linux-x64-gnu@15.3.1':
optional: true
'@next/swc-linux-x64-musl@15.2.4':
'@next/swc-linux-x64-musl@15.3.1':
optional: true
'@next/swc-win32-arm64-msvc@15.2.4':
'@next/swc-win32-arm64-msvc@15.3.1':
optional: true
'@next/swc-win32-x64-msvc@15.2.4':
'@next/swc-win32-x64-msvc@15.3.1':
optional: true
'@nodelib/fs.scandir@2.1.5':
@ -6119,13 +6311,13 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next-auth@4.24.11(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.27.0
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.26.5
@ -6134,9 +6326,9 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
uuid: 8.3.2
next@15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.4
'@next/env': 15.3.1
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -6146,23 +6338,23 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.4
'@next/swc-darwin-x64': 15.2.4
'@next/swc-linux-arm64-gnu': 15.2.4
'@next/swc-linux-arm64-musl': 15.2.4
'@next/swc-linux-x64-gnu': 15.2.4
'@next/swc-linux-x64-musl': 15.2.4
'@next/swc-win32-arm64-msvc': 15.2.4
'@next/swc-win32-x64-msvc': 15.2.4
'@next/swc-darwin-arm64': 15.3.1
'@next/swc-darwin-x64': 15.3.1
'@next/swc-linux-arm64-gnu': 15.3.1
'@next/swc-linux-arm64-musl': 15.3.1
'@next/swc-linux-x64-gnu': 15.3.1
'@next/swc-linux-x64-musl': 15.3.1
'@next/swc-win32-arm64-msvc': 15.3.1
'@next/swc-win32-x64-msvc': 15.3.1
'@opentelemetry/api': 1.9.0
sharp: 0.33.5
sharp: 0.34.1
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
nextjs-toploader@1.6.12(next@15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
nextjs-toploader@1.6.12(next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
next: 15.2.4(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
nprogress: 0.2.0
prop-types: 15.8.1
react: 19.0.0
@ -6666,6 +6858,34 @@ snapshots:
'@img/sharp-win32-ia32': 0.33.5
'@img/sharp-win32-x64': 0.33.5
sharp@0.34.1:
dependencies:
color: 4.2.3
detect-libc: 2.0.3
semver: 7.7.1
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.1
'@img/sharp-darwin-x64': 0.34.1
'@img/sharp-libvips-darwin-arm64': 1.1.0
'@img/sharp-libvips-darwin-x64': 1.1.0
'@img/sharp-libvips-linux-arm': 1.1.0
'@img/sharp-libvips-linux-arm64': 1.1.0
'@img/sharp-libvips-linux-ppc64': 1.1.0
'@img/sharp-libvips-linux-s390x': 1.1.0
'@img/sharp-libvips-linux-x64': 1.1.0
'@img/sharp-libvips-linuxmusl-arm64': 1.1.0
'@img/sharp-libvips-linuxmusl-x64': 1.1.0
'@img/sharp-linux-arm': 0.34.1
'@img/sharp-linux-arm64': 0.34.1
'@img/sharp-linux-s390x': 0.34.1
'@img/sharp-linux-x64': 0.34.1
'@img/sharp-linuxmusl-arm64': 0.34.1
'@img/sharp-linuxmusl-x64': 0.34.1
'@img/sharp-wasm32': 0.34.1
'@img/sharp-win32-ia32': 0.34.1
'@img/sharp-win32-x64': 0.34.1
optional: true
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0