mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #480 from learnhouse/feat/more-ux-upgrades
More UX changes
This commit is contained in:
commit
66c6ea8779
21 changed files with 1385 additions and 841 deletions
|
|
@ -36,8 +36,8 @@ engine = create_engine(
|
||||||
learnhouse_config.database_config.sql_connection_string, # type: ignore
|
learnhouse_config.database_config.sql_connection_string, # type: ignore
|
||||||
echo=False,
|
echo=False,
|
||||||
pool_pre_ping=True, # type: ignore
|
pool_pre_ping=True, # type: ignore
|
||||||
pool_size=10,
|
pool_size=5,
|
||||||
max_overflow=20,
|
max_overflow=0,
|
||||||
pool_recycle=300, # Recycle connections after 5 minutes
|
pool_recycle=300, # Recycle connections after 5 minutes
|
||||||
pool_timeout=30
|
pool_timeout=30
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -133,42 +133,40 @@ async def get_course_meta(
|
||||||
# Avoid circular import
|
# Avoid circular import
|
||||||
from src.services.courses.chapters import get_course_chapters
|
from src.services.courses.chapters import get_course_chapters
|
||||||
|
|
||||||
# Get course with a single query
|
# Get course with authors in a single query using joins
|
||||||
course_statement = select(Course).where(Course.course_uuid == course_uuid)
|
course_statement = (
|
||||||
course = db_session.exec(course_statement).first()
|
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(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="Course not found",
|
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
|
# RBAC check
|
||||||
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
||||||
|
|
||||||
# Start async tasks concurrently
|
# Start async tasks concurrently
|
||||||
tasks = []
|
tasks = []
|
||||||
|
|
||||||
# Task 1: Get course authors with their roles
|
# Task 1: Get course chapters
|
||||||
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
|
|
||||||
async def get_chapters():
|
async def get_chapters():
|
||||||
# Ensure course.id is not None
|
# Ensure course.id is not None
|
||||||
if course.id is None:
|
if course.id is None:
|
||||||
return []
|
return []
|
||||||
return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities)
|
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():
|
async def get_trail():
|
||||||
if isinstance(current_user, AnonymousUser):
|
if isinstance(current_user, AnonymousUser):
|
||||||
return None
|
return None
|
||||||
|
|
@ -177,12 +175,11 @@ async def get_course_meta(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add tasks to the list
|
# Add tasks to the list
|
||||||
tasks.append(get_authors())
|
|
||||||
tasks.append(get_chapters())
|
tasks.append(get_chapters())
|
||||||
tasks.append(get_trail())
|
tasks.append(get_trail())
|
||||||
|
|
||||||
# Run all tasks concurrently
|
# Run all tasks concurrently
|
||||||
author_results, chapters, trail = await asyncio.gather(*tasks)
|
chapters, trail = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# Convert to AuthorWithRole objects
|
# Convert to AuthorWithRole objects
|
||||||
authors = [
|
authors = [
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
|
||||||
// Get Org context information
|
// Get Org context information
|
||||||
const course_meta = await getCourseMetadata(
|
const course_meta = await getCourseMetadata(
|
||||||
params.courseid,
|
params.courseid,
|
||||||
{ revalidate: 0, tags: ['courses'] },
|
{ revalidate: 30, tags: ['courses'] },
|
||||||
access_token ? access_token : null
|
access_token ? access_token : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -41,7 +41,7 @@ const EditActivity = async (params: any) => {
|
||||||
const courseid = (await params.params).courseid
|
const courseid = (await params.params).courseid
|
||||||
const courseInfo = await getCourseMetadata(
|
const courseInfo = await getCourseMetadata(
|
||||||
courseid,
|
courseid,
|
||||||
{ revalidate: 0, tags: ['courses'] },
|
{ revalidate: 30, tags: ['courses'] },
|
||||||
access_token ? access_token : null
|
access_token ? access_token : null
|
||||||
)
|
)
|
||||||
const activity = await getActivityWithAuthHeader(
|
const activity = await getActivityWithAuthHeader(
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,16 @@
|
||||||
'use client'
|
'use client'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { getAPIUrl, getUriWithOrg } from '@services/config/config'
|
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 { 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 { 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 { usePathname, useRouter } from 'next/navigation'
|
||||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
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 { useOrg } from '@components/Contexts/OrgContext'
|
||||||
import { CourseProvider } from '@components/Contexts/CourseContext'
|
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 { 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 { getAssignmentFromActivityUUID, getFinalGrade, submitAssignmentForGrading } from '@services/courses/assignments'
|
||||||
import AssignmentStudentActivity from '@components/Objects/Activities/Assignment/AssignmentStudentActivity'
|
|
||||||
import { AssignmentProvider } from '@components/Contexts/Assignments/AssignmentContext'
|
import { AssignmentProvider } from '@components/Contexts/Assignments/AssignmentContext'
|
||||||
import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/AssignmentsTaskContext'
|
import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/AssignmentsTaskContext'
|
||||||
import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
|
import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
|
||||||
|
|
@ -34,6 +26,30 @@ import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterD
|
||||||
import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar'
|
import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar'
|
||||||
import CourseEndView from '@components/Pages/Activity/CourseEndView'
|
import CourseEndView from '@components/Pages/Activity/CourseEndView'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
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 = () => (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="relative w-6 h-6">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full border-2 border-gray-100 rounded-full"></div>
|
||||||
|
<div className="absolute top-0 left-0 w-full h-full border-2 border-gray-400 rounded-full animate-spin border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
interface ActivityClientProps {
|
interface ActivityClientProps {
|
||||||
activityid: string
|
activityid: string
|
||||||
|
|
@ -52,6 +68,31 @@ interface ActivityActionsProps {
|
||||||
showNavigation?: boolean
|
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) {
|
function ActivityActions({ activity, activityid, course, orgslug, assignment, showNavigation = true }: ActivityActionsProps) {
|
||||||
const session = useLHSession() as any;
|
const session = useLHSession() as any;
|
||||||
const { contributorStatus } = useContributorStatus(course.course_uuid);
|
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) {
|
function ActivityClient(props: ActivityClientProps) {
|
||||||
const activityid = props.activityid
|
const activityid = props.activityid
|
||||||
const courseuuid = props.courseuuid
|
const courseuuid = props.courseuuid
|
||||||
|
|
@ -110,37 +171,55 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
const { contributorStatus } = useContributorStatus(courseuuid);
|
const { contributorStatus } = useContributorStatus(courseuuid);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// Function to find the current activity's position in the course
|
// Memoize activity position calculation
|
||||||
const findActivityPosition = () => {
|
const { allActivities, currentIndex } = useActivityPosition(course, activityid);
|
||||||
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
|
// Get previous and next activities
|
||||||
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
|
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
|
||||||
const nextActivity = currentIndex < allActivities.length - 1 ? 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 (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<Canva content={activity.content} activity={activity} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
case 'TYPE_VIDEO':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<VideoActivity course={course} activity={activity} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
case 'TYPE_DOCUMENT':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<DocumentPdfActivity course={course} activity={activity} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
case 'TYPE_ASSIGNMENT':
|
||||||
|
return assignment ? (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
|
||||||
|
<AssignmentsTaskProvider>
|
||||||
|
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
|
||||||
|
<AssignmentStudentActivity />
|
||||||
|
</AssignmentSubmissionProvider>
|
||||||
|
</AssignmentsTaskProvider>
|
||||||
|
</AssignmentProvider>
|
||||||
|
</Suspense>
|
||||||
|
) : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [activity, course, assignment]);
|
||||||
|
|
||||||
// Navigate to an activity
|
// Navigate to an activity
|
||||||
const navigateToActivity = (activity: any) => {
|
const navigateToActivity = (activity: any) => {
|
||||||
if (!activity) return;
|
if (!activity) return;
|
||||||
|
|
@ -205,6 +284,7 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CourseProvider courseuuid={course?.course_uuid}>
|
<CourseProvider courseuuid={course?.course_uuid}>
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AIChatBotProvider>
|
<AIChatBotProvider>
|
||||||
{isFocusMode ? (
|
{isFocusMode ? (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
@ -225,56 +305,9 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
>
|
>
|
||||||
<div className="container mx-auto px-4 py-2">
|
<div className="container mx-auto px-4 py-2">
|
||||||
<div className="flex items-center justify-between h-14">
|
<div className="flex items-center justify-between h-14">
|
||||||
<div className="flex items-center space-x-2">
|
{/* Progress Indicator - Moved to left */}
|
||||||
<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
|
<motion.div
|
||||||
initial={isInitialRender.current ? false : { opacity: 0, y: -20 }}
|
initial={isInitialRender.current ? false : { opacity: 0, x: -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-[60px] h-[34px] 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-sm">Course </p>
|
|
||||||
<h1 className="font-bold text-gray-950 text-lg first-letter:uppercase">
|
|
||||||
{course.name}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Progress Indicator */}
|
|
||||||
<motion.div
|
|
||||||
initial={isInitialRender.current ? false : { opacity: 0, x: 20 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: 0.2 }}
|
transition={{ delay: 0.2 }}
|
||||||
className="flex items-center space-x-2"
|
className="flex items-center space-x-2"
|
||||||
|
|
@ -311,6 +344,59 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
{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}
|
{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>
|
</div>
|
||||||
</motion.div>
|
</motion.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-[60px] h-[34px] 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-sm">Course </p>
|
||||||
|
<h1 className="font-bold text-gray-950 text-lg first-letter:uppercase">
|
||||||
|
{course.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Minimize and Chapters - Moved to right */}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<ActivityChapterDropdown
|
||||||
|
course={course}
|
||||||
|
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -331,33 +417,7 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
>
|
>
|
||||||
{/* Activity Types */}
|
{/* Activity Types */}
|
||||||
<div>
|
<div>
|
||||||
{activity.activity_type == 'TYPE_DYNAMIC' && (
|
{activityContent}
|
||||||
<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>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -444,6 +504,11 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 pt-0">
|
<div className="space-y-4 pt-0">
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
|
<ActivityBreadcrumbs
|
||||||
|
course={course}
|
||||||
|
activity={activity}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
<div className="space-y-4 pb-4 activity-info-section">
|
<div className="space-y-4 pb-4 activity-info-section">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div className="flex space-x-6">
|
<div className="flex space-x-6">
|
||||||
|
|
@ -464,7 +529,7 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col -space-y-1">
|
<div className="flex flex-col -space-y-1">
|
||||||
<p className="font-bold text-gray-700 text-md">Course </p>
|
<p className="font-bold text-gray-700 text-md">Course </p>
|
||||||
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
|
<h1 className="font-bold text-gray-950 text-3xl first-letter:uppercase">
|
||||||
{course.name}
|
{course.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -494,22 +559,11 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
current_activity={activityid}
|
current_activity={activityid}
|
||||||
orgslug={orgslug}
|
orgslug={orgslug}
|
||||||
course={course}
|
course={course}
|
||||||
|
enableNavigation={true}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex justify-between items-center w-full">
|
<div className="flex justify-between items-center w-full">
|
||||||
<div className="flex flex-1/3 items-center space-x-3">
|
<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">
|
<div className="flex flex-col -space-y-1">
|
||||||
<p className="font-bold text-gray-700 text-md">
|
<p className="font-bold text-gray-700 text-md">
|
||||||
Chapter : {getChapterNameByActivityId(course, activity.id)}
|
Chapter : {getChapterNameByActivityId(course, activity.id)}
|
||||||
|
|
@ -517,6 +571,80 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
|
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
|
||||||
{activity.name}
|
{activity.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
{/* Authors and Dates Section */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-2">
|
||||||
|
{/* Avatars */}
|
||||||
|
{course.authors && course.authors.length > 0 && (
|
||||||
|
<div className="flex -space-x-3">
|
||||||
|
{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').slice(0, 3).map((author: any, idx: number) => (
|
||||||
|
<div key={author.user.user_uuid} className="relative z-[${10-idx}]">
|
||||||
|
<UserAvatar
|
||||||
|
border="border-2"
|
||||||
|
rounded="rounded-full"
|
||||||
|
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
|
||||||
|
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
|
||||||
|
width={26}
|
||||||
|
showProfilePopup={true}
|
||||||
|
userId={author.user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 3 && (
|
||||||
|
<div className="flex items-center justify-center bg-neutral-100 text-neutral-600 font-medium rounded-full border-2 border-white shadow-sm w-9 h-9 text-xs z-0">
|
||||||
|
+{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Author names */}
|
||||||
|
{course.authors && course.authors.length > 0 && (
|
||||||
|
<div className="text-xs text-gray-700 font-medium flex items-center gap-1">
|
||||||
|
{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 1 && (
|
||||||
|
<span>Co-created by </span>
|
||||||
|
)}
|
||||||
|
{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').slice(0, 2).map((author: any, idx: number, arr: any[]) => (
|
||||||
|
<span key={author.user.user_uuid}>
|
||||||
|
{author.user.first_name && author.user.last_name
|
||||||
|
? `${author.user.first_name} ${author.user.last_name}`
|
||||||
|
: `@${author.user.username}`}
|
||||||
|
{idx === 0 && arr.length > 1 ? ' & ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length > 2 && (
|
||||||
|
<ToolTip
|
||||||
|
content={
|
||||||
|
<div className="p-2">
|
||||||
|
{course.authors
|
||||||
|
.filter((a: any) => a.authorship_status === 'ACTIVE')
|
||||||
|
.slice(2)
|
||||||
|
.map((author: any) => (
|
||||||
|
<div key={author.user.user_uuid} className="text-white text-sm py-1">
|
||||||
|
{author.user.first_name && author.user.last_name
|
||||||
|
? `${author.user.first_name} ${author.user.last_name}`
|
||||||
|
: `@${author.user.username}`}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="bg-gray-100 hover:bg-gray-200 text-gray-600 px-2 py-0.5 rounded-md cursor-pointer text-xs font-medium transition-colors duration-200">
|
||||||
|
+{course.authors.filter((a: any) => a.authorship_status === 'ACTIVE').length - 2}
|
||||||
|
</div>
|
||||||
|
</ToolTip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="flex items-center text-xs text-gray-500 gap-2">
|
||||||
|
<span>
|
||||||
|
Created on {new Date(course.creation_date).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||||
|
</span>
|
||||||
|
<span className="mx-1">•</span>
|
||||||
|
<span>
|
||||||
|
Last updated {getRelativeTime(new Date(course.updated_at || course.last_updated || course.creation_date))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 items-center">
|
<div className="flex space-x-2 items-center">
|
||||||
|
|
@ -525,6 +653,11 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
|
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
|
||||||
<>
|
<>
|
||||||
<AIActivityAsk activity={activity} />
|
<AIActivityAsk activity={activity} />
|
||||||
|
<ActivityChapterDropdown
|
||||||
|
course={course}
|
||||||
|
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
|
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
|
||||||
<Link
|
<Link
|
||||||
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
|
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
|
||||||
|
|
@ -557,37 +690,20 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
{activity.content.paid_access == false ? (
|
{activity.content.paid_access == false ? (
|
||||||
<PaidCourseActivityDisclaimer course={course} />
|
<PaidCourseActivityDisclaimer course={course} />
|
||||||
) : (
|
) : (
|
||||||
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
|
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor} relative`}>
|
||||||
{/* Activity Types */}
|
<button
|
||||||
<div>
|
onClick={() => setIsFocusMode(true)}
|
||||||
{activity.activity_type == 'TYPE_DYNAMIC' && (
|
className="absolute top-4 right-4 bg-white/80 hover:bg-white nice-shadow p-2 rounded-full cursor-pointer transition-all duration-200 group overflow-hidden z-50 pointer-events-auto"
|
||||||
<Canva content={activity.content} activity={activity} />
|
title="Enter focus mode"
|
||||||
)}
|
>
|
||||||
{activity.activity_type == 'TYPE_VIDEO' && (
|
<div className="flex items-center">
|
||||||
<VideoActivity course={course} activity={activity} />
|
<Maximize2 size={16} className="text-gray-700" />
|
||||||
)}
|
<span className="text-xs font-bold text-gray-700 opacity-0 group-hover:opacity-100 transition-all duration-200 w-0 group-hover:w-auto group-hover:ml-2 whitespace-nowrap">
|
||||||
{activity.activity_type == 'TYPE_DOCUMENT' && (
|
Focus Mode
|
||||||
<DocumentPdfActivity
|
</span>
|
||||||
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>
|
||||||
|
</button>
|
||||||
|
{activityContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -595,14 +711,29 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
|
|
||||||
{/* Activity Actions below the content box */}
|
{/* Activity Actions below the content box */}
|
||||||
{activity && activity.published == true && activity.content.paid_access != false && (
|
{activity && activity.published == true && activity.content.paid_access != false && (
|
||||||
<div className="flex justify-end mt-4">
|
<div className="flex justify-between items-center mt-4 w-full">
|
||||||
|
<div>
|
||||||
|
<PreviousActivityButton
|
||||||
|
course={course}
|
||||||
|
currentActivityId={activity.id}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
<ActivityActions
|
<ActivityActions
|
||||||
activity={activity}
|
activity={activity}
|
||||||
activityid={activityid}
|
activityid={activityid}
|
||||||
course={course}
|
course={course}
|
||||||
orgslug={orgslug}
|
orgslug={orgslug}
|
||||||
assignment={assignment}
|
assignment={assignment}
|
||||||
|
showNavigation={false}
|
||||||
/>
|
/>
|
||||||
|
<NextActivityButton
|
||||||
|
course={course}
|
||||||
|
currentActivityId={activity.id}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -623,6 +754,7 @@ function ActivityClient(props: ActivityClientProps) {
|
||||||
</GeneralWrapperStyled>
|
</GeneralWrapperStyled>
|
||||||
)}
|
)}
|
||||||
</AIChatBotProvider>
|
</AIChatBotProvider>
|
||||||
|
</Suspense>
|
||||||
</CourseProvider>
|
</CourseProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
@ -638,6 +770,51 @@ export function MarkStatus(props: {
|
||||||
const session = useLHSession() as any;
|
const session = useLHSession() as any;
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)')
|
const isMobile = useMediaQuery('(max-width: 768px)')
|
||||||
const [isLoading, setIsLoading] = React.useState(false);
|
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 = (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 16v-4" />
|
||||||
|
<path d="M12 8h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
const areAllActivitiesCompleted = () => {
|
const areAllActivitiesCompleted = () => {
|
||||||
const run = props.course.trail.runs.find(
|
const run = props.course.trail.runs.find(
|
||||||
|
|
@ -648,7 +825,6 @@ export function MarkStatus(props: {
|
||||||
let totalActivities = 0;
|
let totalActivities = 0;
|
||||||
let completedActivities = 0;
|
let completedActivities = 0;
|
||||||
|
|
||||||
// Count all activities and completed activities
|
|
||||||
props.course.chapters.forEach((chapter: any) => {
|
props.course.chapters.forEach((chapter: any) => {
|
||||||
chapter.activities.forEach((activity: any) => {
|
chapter.activities.forEach((activity: any) => {
|
||||||
totalActivities++;
|
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;
|
return completedActivities >= totalActivities - 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function markActivityAsCompleteFront() {
|
async function markActivityAsCompleteFront() {
|
||||||
try {
|
try {
|
||||||
// Check if this will be the last activity to complete
|
|
||||||
const willCompleteAll = areAllActivitiesCompleted();
|
const willCompleteAll = areAllActivitiesCompleted();
|
||||||
console.log('Will complete all?', willCompleteAll);
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
// refresh the page after marking the activity as complete
|
||||||
|
await revalidateTags(['courses'], props.orgslug);
|
||||||
|
router.refresh();
|
||||||
await markActivityAsComplete(
|
await markActivityAsComplete(
|
||||||
props.orgslug,
|
props.orgslug,
|
||||||
props.course.course_uuid,
|
props.course.course_uuid,
|
||||||
|
|
@ -684,11 +854,9 @@ export function MarkStatus(props: {
|
||||||
session.data?.tokens?.access_token
|
session.data?.tokens?.access_token
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mutate the course data
|
|
||||||
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
||||||
|
|
||||||
if (willCompleteAll) {
|
if (willCompleteAll) {
|
||||||
console.log('Redirecting to end page...');
|
|
||||||
const cleanCourseUuid = props.course.course_uuid.replace('course_', '');
|
const cleanCourseUuid = props.course.course_uuid.replace('course_', '');
|
||||||
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`);
|
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -705,14 +873,13 @@ export function MarkStatus(props: {
|
||||||
async function unmarkActivityAsCompleteFront() {
|
async function unmarkActivityAsCompleteFront() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const trail = await unmarkActivityAsComplete(
|
await unmarkActivityAsComplete(
|
||||||
props.orgslug,
|
props.orgslug,
|
||||||
props.course.course_uuid,
|
props.course.course_uuid,
|
||||||
props.activity.activity_uuid,
|
props.activity.activity_uuid,
|
||||||
session.data?.tokens?.access_token
|
session.data?.tokens?.access_token
|
||||||
);
|
);
|
||||||
|
await revalidateTags(['courses'], props.orgslug);
|
||||||
// Mutate the course data to trigger re-render
|
|
||||||
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -737,24 +904,13 @@ export function MarkStatus(props: {
|
||||||
<>
|
<>
|
||||||
{isActivityCompleted() ? (
|
{isActivityCompleted() ? (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white">
|
<div className="relative">
|
||||||
<i>
|
|
||||||
<Check size={17}></Check>
|
|
||||||
</i>{' '}
|
|
||||||
<i className="not-italic text-xs font-bold">Complete</i>
|
|
||||||
</div>
|
|
||||||
<ToolTip
|
|
||||||
content="Unmark as complete"
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
confirmationButtonText="Unmark Activity"
|
confirmationButtonText="Unmark Activity"
|
||||||
confirmationMessage="Are you sure you want to unmark this activity as complete? This will affect your course progress."
|
confirmationMessage="Are you sure you want to unmark this activity as complete? This will affect your course progress."
|
||||||
dialogTitle="Unmark activity as complete"
|
dialogTitle="Unmark activity as complete"
|
||||||
dialogTrigger={
|
dialogTrigger={
|
||||||
<div
|
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-1 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out">
|
||||||
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-red-400 rounded-full p-2 drop-shadow-md flex items-center text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="animate-spin">
|
<div className="animate-spin">
|
||||||
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" viewBox="0 0 24 24">
|
||||||
|
|
@ -763,19 +919,43 @@ export function MarkStatus(props: {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<X size={17} />
|
<svg
|
||||||
|
width="17"
|
||||||
|
height="17"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<path d="M7 12l3 3 7-7" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-xs font-bold">Complete</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
functionToExecute={unmarkActivityAsCompleteFront}
|
functionToExecute={unmarkActivityAsCompleteFront}
|
||||||
status="warning"
|
status="warning"
|
||||||
/>
|
/>
|
||||||
</ToolTip>
|
{showMarkedTooltip && (
|
||||||
|
<MiniInfoTooltip
|
||||||
|
icon={infoIcon}
|
||||||
|
message="Click the checkbox to unmark as complete if needed"
|
||||||
|
onClose={handleMarkedTooltipClose}
|
||||||
|
iconColor="text-teal-600"
|
||||||
|
iconSize={24}
|
||||||
|
width="w-64"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-gray-800 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`}
|
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-gray-800 rounded-full px-5 drop-shadow-md flex items-center space-x-1 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
|
||||||
onClick={!isLoading ? markActivityAsCompleteFront : undefined}
|
onClick={!isLoading ? markActivityAsCompleteFront : undefined}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -786,11 +966,31 @@ export function MarkStatus(props: {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<i>
|
<svg
|
||||||
<Check size={17}></Check>
|
width="17"
|
||||||
</i>
|
height="17"
|
||||||
)}{' '}
|
viewBox="0 0 24 24"
|
||||||
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</i>}
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</span>
|
||||||
|
</div>
|
||||||
|
{showUnmarkedTooltip && (
|
||||||
|
<MiniInfoTooltip
|
||||||
|
icon={infoIcon}
|
||||||
|
message="Click the checkbox to mark this activity as complete"
|
||||||
|
onClose={handleUnmarkedTooltipClose}
|
||||||
|
iconColor="text-gray-600"
|
||||||
|
iconSize={24}
|
||||||
|
width="w-64"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ type Session = {
|
||||||
async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) {
|
async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) {
|
||||||
return await getCourseMetadata(
|
return await getCourseMetadata(
|
||||||
courseuuid,
|
courseuuid,
|
||||||
{ revalidate: 0, tags: ['courses'] },
|
{ revalidate: 30, tags: ['courses'] },
|
||||||
access_token || null
|
access_token || null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => {
|
||||||
fetchCourseMetadata(courseuuid, access_token),
|
fetchCourseMetadata(courseuuid, access_token),
|
||||||
getActivityWithAuthHeader(
|
getActivityWithAuthHeader(
|
||||||
activityid,
|
activityid,
|
||||||
{ revalidate: 0, tags: ['activities'] },
|
{ revalidate: 60, tags: ['activities'] },
|
||||||
access_token || null
|
access_token || null
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useMediaQuery } from 'usehooks-ts'
|
||||||
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
|
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
|
||||||
import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile'
|
import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile'
|
||||||
import CourseAuthors from '@components/Objects/Courses/CourseAuthors/CourseAuthors'
|
import CourseAuthors from '@components/Objects/Courses/CourseAuthors/CourseAuthors'
|
||||||
|
import CourseBreadcrumbs from '@components/Pages/Courses/CourseBreadcrumbs'
|
||||||
|
|
||||||
const CourseClient = (props: any) => {
|
const CourseClient = (props: any) => {
|
||||||
const [learnings, setLearnings] = useState<any>([])
|
const [learnings, setLearnings] = useState<any>([])
|
||||||
|
|
@ -127,7 +128,11 @@ const CourseClient = (props: any) => {
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GeneralWrapperStyled>
|
<GeneralWrapperStyled>
|
||||||
<div className="pb-2 pt-5 flex flex-col md:flex-row justify-between items-start md:items-center">
|
<CourseBreadcrumbs
|
||||||
|
course={course}
|
||||||
|
orgslug={orgslug}
|
||||||
|
/>
|
||||||
|
<div className="pb-2 pt-3 flex flex-col md:flex-row justify-between items-start md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-md font-bold text-gray-400 pb-2">Course</p>
|
<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>
|
<h1 className="text-3xl md:text-3xl -mt-3 font-bold">{course.name}</h1>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
|
||||||
})
|
})
|
||||||
const course_meta = await getCourseMetadata(
|
const course_meta = await getCourseMetadata(
|
||||||
params.courseuuid,
|
params.courseuuid,
|
||||||
{ revalidate: 1800, tags: ['courses'] },
|
{ revalidate: 30, tags: ['courses'] },
|
||||||
access_token ? access_token : null
|
access_token ? access_token : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ const CoursePage = async (params: any) => {
|
||||||
// Fetch course metadata once
|
// Fetch course metadata once
|
||||||
const course_meta = await getCourseMetadata(
|
const course_meta = await getCourseMetadata(
|
||||||
params.params.courseuuid,
|
params.params.courseuuid,
|
||||||
{ revalidate: 0, tags: ['courses'] },
|
{ revalidate: 30, tags: ['courses'] },
|
||||||
access_token ? access_token : null
|
access_token ? access_token : null
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -33,12 +33,16 @@ import TableRow from '@tiptap/extension-table-row'
|
||||||
import TableCell from '@tiptap/extension-table-cell'
|
import TableCell from '@tiptap/extension-table-cell'
|
||||||
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
|
import UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
|
||||||
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
|
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
|
||||||
|
import TableOfContents from './TableOfContents'
|
||||||
|
import { CustomHeading } from './CustomHeadingExtenstion'
|
||||||
|
|
||||||
interface Editor {
|
interface Editor {
|
||||||
content: string
|
content: string
|
||||||
activity: any
|
activity: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function Canva(props: Editor) {
|
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.
|
* 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,
|
editable: isEditable,
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
heading: false,
|
||||||
bulletList: {
|
bulletList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: 'bullet-list',
|
class: 'bullet-list',
|
||||||
|
|
@ -70,6 +75,7 @@ function Canva(props: Editor) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
CustomHeading,
|
||||||
NoTextInput,
|
NoTextInput,
|
||||||
// Custom Extensions
|
// Custom Extensions
|
||||||
InfoCallout.configure({
|
InfoCallout.configure({
|
||||||
|
|
@ -137,7 +143,10 @@ function Canva(props: Editor) {
|
||||||
<EditorOptionsProvider options={{ isEditable: false }}>
|
<EditorOptionsProvider options={{ isEditable: false }}>
|
||||||
<CanvaWrapper>
|
<CanvaWrapper>
|
||||||
<AICanvaToolkit activity={props.activity} editor={editor} />
|
<AICanvaToolkit activity={props.activity} editor={editor} />
|
||||||
|
<ContentWrapper>
|
||||||
|
<TableOfContents editor={editor} />
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
|
</ContentWrapper>
|
||||||
</CanvaWrapper>
|
</CanvaWrapper>
|
||||||
</EditorOptionsProvider>
|
</EditorOptionsProvider>
|
||||||
)
|
)
|
||||||
|
|
@ -146,33 +155,17 @@ function Canva(props: Editor) {
|
||||||
const CanvaWrapper = styled.div`
|
const CanvaWrapper = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
`
|
||||||
|
|
||||||
.bubble-menu {
|
const ContentWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: #0d0d0d;
|
width: 100%;
|
||||||
padding: 0.2rem;
|
height: 100%;
|
||||||
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
|
|
||||||
|
|
||||||
.ProseMirror {
|
.ProseMirror {
|
||||||
// Workaround to disable editor from being edited by the user.
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
// disable chrome outline
|
||||||
caret-color: transparent;
|
caret-color: transparent;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|
|
||||||
|
|
@ -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<HeadingItem[]>([])
|
||||||
|
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 (
|
||||||
|
<TOCCard>
|
||||||
|
<TOCList>
|
||||||
|
{headings.map((heading, index) => (
|
||||||
|
<TOCItem key={index} level={heading.level}>
|
||||||
|
<span className="toc-check"><Check size={15} strokeWidth={1.7} /></span>
|
||||||
|
<a className={`toc-link toc-link-h${heading.level}`} href={`#${heading.id}`}>{heading.text}</a>
|
||||||
|
</TOCItem>
|
||||||
|
))}
|
||||||
|
</TOCList>
|
||||||
|
</TOCCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -197,6 +197,7 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil
|
||||||
|
|
||||||
if (firstActivity) {
|
if (firstActivity) {
|
||||||
// Redirect to the first activity
|
// Redirect to the first activity
|
||||||
|
await revalidateTags(['activities'], orgslug)
|
||||||
router.push(
|
router.push(
|
||||||
getUriWithOrg(orgslug, '') +
|
getUriWithOrg(orgslug, '') +
|
||||||
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
|
`/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)
|
console.error('Failed to perform course action:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsActionLoading(false)
|
setIsActionLoading(false)
|
||||||
|
await revalidateTags(['courses'], orgslug)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
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 React, { useState } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
|
|
@ -100,11 +100,11 @@ function InfoCalloutComponent(props: any) {
|
||||||
const getVariantClasses = () => {
|
const getVariantClasses = () => {
|
||||||
switch(options.variant) {
|
switch(options.variant) {
|
||||||
case 'filled':
|
case 'filled':
|
||||||
return 'bg-blue-500 text-white';
|
return 'bg-gray-300 text-gray-700';
|
||||||
case 'outlined':
|
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:
|
default:
|
||||||
return 'bg-blue-200 text-blue-900';
|
return 'bg-gray-100 text-gray-600';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,12 +119,12 @@ function InfoCalloutComponent(props: any) {
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper>
|
<NodeViewWrapper>
|
||||||
<InfoCalloutWrapper
|
<InfoCalloutWrapper
|
||||||
className={`flex items-center rounded-lg shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
|
className={`flex items-center rounded-xl shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
|
||||||
contentEditable={isEditable}
|
contentEditable={isEditable}
|
||||||
size={options.size}
|
size={options.size}
|
||||||
>
|
>
|
||||||
<IconWrapper size={options.size}>
|
<IconWrapper size={options.size}>
|
||||||
<AlertCircle />
|
<Info />
|
||||||
</IconWrapper>
|
</IconWrapper>
|
||||||
<ContentWrapper className="grow">
|
<ContentWrapper className="grow">
|
||||||
<NodeViewContent contentEditable={isEditable} className="content" />
|
<NodeViewContent contentEditable={isEditable} className="content" />
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,41 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
import { motion } from 'framer-motion'
|
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() {
|
function PageLoading() {
|
||||||
return (
|
return (
|
||||||
<motion.main
|
<div className="fixed inset-0 flex items-center justify-center">
|
||||||
variants={variants}
|
|
||||||
initial="hidden"
|
|
||||||
animate="enter"
|
|
||||||
exit="exit"
|
|
||||||
transition={{ type: 'linear' }}
|
|
||||||
className=""
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-20 transition-all">
|
|
||||||
<div className="flex flex-col items-center justify-center h-40">
|
|
||||||
{/* Animated dots */}
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
{[0, 1, 2, 3, 4].map((i) => (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={i}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
custom={i}
|
animate={{
|
||||||
variants={dotVariants}
|
opacity: [0, 0.5, 1],
|
||||||
initial="initial"
|
scale: 1,
|
||||||
animate="animate"
|
transition: {
|
||||||
className="w-4 h-4 rounded-full bg-gray-500 dark:bg-gray-400"
|
duration: 0.8,
|
||||||
/>
|
scale: {
|
||||||
))}
|
type: "spring",
|
||||||
</div>
|
stiffness: 50,
|
||||||
|
damping: 15,
|
||||||
<motion.p
|
delay: 0.2
|
||||||
className="mt-6 text-sm text-gray-500 dark:text-gray-400"
|
},
|
||||||
initial={{ opacity: 0 }}
|
opacity: {
|
||||||
animate={{ opacity: [0, 1, 0] }}
|
duration: 0.6,
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
times: [0, 0.6, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
exit={{
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.95,
|
||||||
|
transition: {
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeOut"
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Loading...
|
<Loader2 className="w-10 h-10 text-gray-400 animate-spin" />
|
||||||
</motion.p>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</motion.main>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
47
apps/web/components/Objects/MiniInfoTooltip.tsx
Normal file
47
apps/web/components/Objects/MiniInfoTooltip.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 10 }}
|
||||||
|
className={`absolute -top-20 left-1/2 transform -translate-x-1/2 bg-white rounded-lg nice-shadow p-3 ${width}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{icon && (
|
||||||
|
<div className={`${iconColor} flex-shrink-0`} style={{ width: iconSize, height: iconSize }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-gray-700">{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-4 h-4 bg-white rotate-45"></div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-1 right-1 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/web/components/Pages/Activity/ActivityBreadcrumbs.tsx
Normal file
35
apps/web/components/Pages/Activity/ActivityBreadcrumbs.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="text-gray-400 tracking-tight font-medium text-sm flex space-x-1 mb-4">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="flex space-x-2 items-center">
|
||||||
|
<Book className="text-gray" size={14} />
|
||||||
|
<Link href={getUriWithOrg(orgslug, '') + `/courses`}>
|
||||||
|
Courses
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link href={getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}`}>
|
||||||
|
{course.name}
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<div className="first-letter:uppercase">
|
||||||
|
{activity.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -68,15 +68,16 @@ export default function ActivityChapterDropdown(props: ActivityChapterDropdownPr
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
className="flex items-center justify-center bg-white nice-shadow p-2 rounded-full cursor-pointer"
|
className="bg-white rounded-full px-5 nice-shadow flex items-center space-x-2 p-2.5 text-gray-700 hover:bg-gray-50 transition delay-150 duration-300 ease-in-out"
|
||||||
aria-label="View all activities"
|
aria-label="View all activities"
|
||||||
title="View all activities"
|
title="View all activities"
|
||||||
>
|
>
|
||||||
<ListTree size={16} className="text-gray-700" />
|
<ListTree size={17} />
|
||||||
|
<span className="text-xs font-bold">Chapters</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={`absolute z-50 mt-2 ${isMobile ? 'left-0 w-[90vw] sm:w-72' : 'left-0 w-72'} max-h-[70vh] cursor-pointer overflow-y-auto bg-white rounded-lg shadow-xl border border-gray-200 py-1 animate-in fade-in duration-200`}>
|
<div className={`absolute z-50 mt-2 ${isMobile ? 'right-0 w-[90vw] sm:w-72' : 'right-0 w-72'} max-h-[70vh] cursor-pointer overflow-y-auto bg-white rounded-lg shadow-xl border border-gray-200 py-1 animate-in fade-in duration-200`}>
|
||||||
<div className="px-3 py-1.5 border-b border-gray-100 flex justify-between items-center">
|
<div className="px-3 py-1.5 border-b border-gray-100 flex justify-between items-center">
|
||||||
<h3 className="text-sm font-semibold text-gray-800">Course Content</h3>
|
<h3 className="text-sm font-semibold text-gray-800">Course Content</h3>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import { getUriWithOrg } from '@services/config/config'
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import React, { useEffect, useState, useRef } from 'react'
|
import React, { useEffect, useState, useRef, useMemo, memo } from 'react'
|
||||||
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
||||||
import ActivityChapterDropdown from './ActivityChapterDropdown'
|
import ActivityChapterDropdown from './ActivityChapterDropdown'
|
||||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||||
|
|
@ -15,125 +15,20 @@ interface FixedActivitySecondaryBarProps {
|
||||||
activity: any
|
activity: any
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryBarProps): React.ReactNode {
|
// Memoized navigation buttons component
|
||||||
const router = useRouter();
|
const NavigationButtons = memo(({
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
prevActivity,
|
||||||
const [shouldShow, setShouldShow] = useState(false);
|
nextActivity,
|
||||||
const mainActivityInfoRef = useRef<HTMLDivElement | null>(null);
|
currentIndex,
|
||||||
const org = useOrg() as any;
|
allActivities,
|
||||||
|
navigateToActivity
|
||||||
// Function to find the current activity's position in the course
|
}: {
|
||||||
const findActivityPosition = () => {
|
prevActivity: any,
|
||||||
let allActivities: any[] = [];
|
nextActivity: any,
|
||||||
let currentIndex = -1;
|
currentIndex: number,
|
||||||
|
allActivities: any[],
|
||||||
// Flatten all activities from all chapters
|
navigateToActivity: (activity: any) => void
|
||||||
props.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 === props.currentActivityId.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 = props.course.course_uuid?.replace('course_', '');
|
|
||||||
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
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll);
|
|
||||||
if (mainActivityInfoRef.current) {
|
|
||||||
observer.unobserve(mainActivityInfoRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{shouldShow && (
|
|
||||||
<div
|
|
||||||
className={`fixed top-[60px] left-0 right-0 z-40 bg-white/90 backdrop-blur-xl transition-all duration-300 animate-in fade-in slide-in-from-top ${
|
|
||||||
isScrolled ? 'nice-shadow' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<div className="flex items-center justify-between h-16 py-2">
|
|
||||||
{/* Left Section - Course Info and Navigation */}
|
|
||||||
<div className="flex items-center space-x-2 sm:space-x-4 min-w-0 flex-shrink">
|
|
||||||
<img
|
|
||||||
className="w-[35px] sm:w-[45px] h-[20px] sm:h-[26px] rounded-md object-cover flex-shrink-0"
|
|
||||||
src={`${getCourseThumbnailMediaDirectory(
|
|
||||||
org?.org_uuid,
|
|
||||||
props.course.course_uuid,
|
|
||||||
props.course.thumbnail_image
|
|
||||||
)}`}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<ActivityChapterDropdown
|
|
||||||
course={props.course}
|
|
||||||
currentActivityId={props.currentActivityId}
|
|
||||||
orgslug={props.orgslug}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col -space-y-0.5 min-w-0 hidden sm:block">
|
|
||||||
<p className="text-sm font-medium text-gray-500">Course</p>
|
|
||||||
<h1 className="font-semibold text-gray-900 text-base truncate">
|
|
||||||
{props.course.name}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Section - Navigation Controls */}
|
|
||||||
<div className="flex items-center flex-shrink-0">
|
|
||||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateToActivity(prevActivity)}
|
onClick={() => navigateToActivity(prevActivity)}
|
||||||
|
|
@ -173,11 +68,127 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
||||||
<ChevronRight size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
<ChevronRight size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
NavigationButtons.displayName = 'NavigationButtons';
|
||||||
|
|
||||||
|
// Memoized course info component
|
||||||
|
const CourseInfo = memo(({ course, org }: { course: any, org: any }) => (
|
||||||
|
<div className="flex items-center space-x-2 sm:space-x-4 min-w-0 flex-shrink">
|
||||||
|
<img
|
||||||
|
className="w-[35px] sm:w-[45px] h-[20px] sm:h-[26px] rounded-md object-cover flex-shrink-0"
|
||||||
|
src={`${getCourseThumbnailMediaDirectory(
|
||||||
|
org?.org_uuid,
|
||||||
|
course.course_uuid,
|
||||||
|
course.thumbnail_image
|
||||||
|
)}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col -space-y-0.5 min-w-0 hidden sm:block">
|
||||||
|
<p className="text-sm font-medium text-gray-500">Course</p>
|
||||||
|
<h1 className="font-semibold text-gray-900 text-base truncate">
|
||||||
|
{course.name}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
CourseInfo.displayName = 'CourseInfo';
|
||||||
|
|
||||||
|
export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryBarProps): React.ReactNode {
|
||||||
|
const router = useRouter();
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [shouldShow, setShouldShow] = useState(false);
|
||||||
|
const mainActivityInfoRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const org = useOrg() as any;
|
||||||
|
|
||||||
|
// Memoize activity position calculation
|
||||||
|
const { allActivities, currentIndex } = useMemo(() => {
|
||||||
|
let allActivities: any[] = [];
|
||||||
|
let currentIndex = -1;
|
||||||
|
|
||||||
|
props.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 === props.currentActivityId.replace('activity_', '')) {
|
||||||
|
currentIndex = allActivities.length - 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { allActivities, currentIndex };
|
||||||
|
}, [props.course, props.currentActivityId]);
|
||||||
|
|
||||||
|
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
|
||||||
|
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
|
||||||
|
|
||||||
|
const navigateToActivity = (activity: any) => {
|
||||||
|
if (!activity) return;
|
||||||
|
|
||||||
|
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
|
||||||
|
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setIsScrolled(window.scrollY > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
setShouldShow(!entry.isIntersecting);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
threshold: [0, 0.1, 1],
|
||||||
|
rootMargin: '-80px 0px 0px 0px'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const mainActivityInfo = document.querySelector('.activity-info-section');
|
||||||
|
if (mainActivityInfo) {
|
||||||
|
mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement;
|
||||||
|
observer.observe(mainActivityInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll);
|
||||||
|
if (mainActivityInfoRef.current) {
|
||||||
|
observer.unobserve(mainActivityInfoRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!shouldShow) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed top-[60px] left-0 right-0 z-40 bg-white/90 backdrop-blur-xl transition-all duration-300 animate-in fade-in slide-in-from-top ${
|
||||||
|
isScrolled ? 'nice-shadow' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex items-center justify-between h-16 py-2">
|
||||||
|
<CourseInfo course={props.course} org={org} />
|
||||||
|
|
||||||
|
<div className="flex items-center flex-shrink-0">
|
||||||
|
<NavigationButtons
|
||||||
|
prevActivity={prevActivity}
|
||||||
|
nextActivity={nextActivity}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
allActivities={allActivities}
|
||||||
|
navigateToActivity={navigateToActivity}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,57 +1,52 @@
|
||||||
|
'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 ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||||
import { getUriWithOrg } from '@services/config/config'
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React from 'react'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Video, FileText, Layers, BookOpenCheck, Check } from 'lucide-react'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
course: any
|
course: any
|
||||||
orgslug: string
|
orgslug: string
|
||||||
course_uuid: string
|
course_uuid: string
|
||||||
current_activity?: any
|
current_activity?: string
|
||||||
|
enableNavigation?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActivityIndicators(props: Props) {
|
// Helper functions
|
||||||
const course = props.course
|
function getActivityTypeLabel(activityType: string): string {
|
||||||
const orgslug = props.orgslug
|
switch (activityType) {
|
||||||
const courseid = props.course_uuid.replace('course_', '')
|
case 'TYPE_VIDEO':
|
||||||
|
return 'Video'
|
||||||
const done_activity_style = 'bg-teal-600 hover:bg-teal-700'
|
case 'TYPE_DOCUMENT':
|
||||||
const black_activity_style = 'bg-zinc-300 hover:bg-zinc-400'
|
return 'Document'
|
||||||
const current_activity_style = 'bg-gray-600 animate-pulse hover:bg-gray-700'
|
case 'TYPE_DYNAMIC':
|
||||||
|
return 'Interactive'
|
||||||
const trail = props.course.trail
|
case 'TYPE_ASSIGNMENT':
|
||||||
|
return 'Assignment'
|
||||||
function isActivityDone(activity: any) {
|
default:
|
||||||
let run = props.course.trail?.runs.find(
|
return 'Unknown'
|
||||||
(run: any) => run.course_id == props.course.id
|
|
||||||
)
|
|
||||||
if (run) {
|
|
||||||
return run.steps.find((step: any) => step.activity_id == activity.id)
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActivityCurrent(activity: any) {
|
function getActivityTypeBadgeColor(activityType: string): string {
|
||||||
let activity_uuid = activity.activity_uuid.replace('activity_', '')
|
switch (activityType) {
|
||||||
if (props.current_activity && props.current_activity == activity_uuid) {
|
case 'TYPE_VIDEO':
|
||||||
return true
|
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'
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActivityClass(activity: any) {
|
// Memoized activity type icon component
|
||||||
if (isActivityDone(activity)) {
|
const ActivityTypeIcon = memo(({ activityType }: { activityType: string }) => {
|
||||||
return done_activity_style
|
|
||||||
}
|
|
||||||
if (isActivityCurrent(activity)) {
|
|
||||||
return current_activity_style
|
|
||||||
}
|
|
||||||
return black_activity_style
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActivityTypeIcon = (activityType: string) => {
|
|
||||||
switch (activityType) {
|
switch (activityType) {
|
||||||
case 'TYPE_VIDEO':
|
case 'TYPE_VIDEO':
|
||||||
return <Video size={16} className="text-gray-400" />
|
return <Video size={16} className="text-gray-400" />
|
||||||
|
|
@ -64,55 +59,23 @@ function ActivityIndicators(props: Props) {
|
||||||
default:
|
default:
|
||||||
return <FileText size={16} className="text-gray-400" />
|
return <FileText size={16} className="text-gray-400" />
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const getActivityTypeLabel = (activityType: string) => {
|
ActivityTypeIcon.displayName = 'ActivityTypeIcon';
|
||||||
switch (activityType) {
|
|
||||||
case 'TYPE_VIDEO':
|
|
||||||
return 'Video'
|
|
||||||
case 'TYPE_DOCUMENT':
|
|
||||||
return 'Document'
|
|
||||||
case 'TYPE_DYNAMIC':
|
|
||||||
return 'Page'
|
|
||||||
case 'TYPE_ASSIGNMENT':
|
|
||||||
return 'Assignment'
|
|
||||||
default:
|
|
||||||
return 'Learning Material'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActivityTypeBadgeColor = (activityType: string) => {
|
// Memoized activity tooltip content
|
||||||
switch (activityType) {
|
const ActivityTooltipContent = memo(({
|
||||||
case 'TYPE_VIDEO':
|
activity,
|
||||||
return 'bg-gray-100 text-gray-700 font-bold'
|
isDone,
|
||||||
case 'TYPE_DOCUMENT':
|
isCurrent
|
||||||
return 'bg-gray-100 text-gray-700 font-bold'
|
}: {
|
||||||
case 'TYPE_DYNAMIC':
|
activity: any,
|
||||||
return 'bg-gray-100 text-gray-700 font-bold'
|
isDone: boolean,
|
||||||
case 'TYPE_ASSIGNMENT':
|
isCurrent: boolean
|
||||||
return 'bg-gray-100 text-gray-700 font-bold'
|
}) => (
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-700 font-bold'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-flow-col justify-stretch space-x-6">
|
|
||||||
{course.chapters.map((chapter: any) => {
|
|
||||||
return (
|
|
||||||
<React.Fragment key={chapter.id || `chapter-${chapter.name}`}>
|
|
||||||
<div className="grid grid-flow-col justify-stretch space-x-2">
|
|
||||||
{chapter.activities.map((activity: any) => {
|
|
||||||
const isDone = isActivityDone(activity)
|
|
||||||
const isCurrent = isActivityCurrent(activity)
|
|
||||||
return (
|
|
||||||
<ToolTip
|
|
||||||
sideOffset={8}
|
|
||||||
unstyled
|
|
||||||
content={
|
|
||||||
<div className="bg-white rounded-lg nice-shadow py-3 px-4 min-w-[200px] animate-in fade-in duration-200">
|
<div className="bg-white rounded-lg nice-shadow py-3 px-4 min-w-[200px] animate-in fade-in duration-200">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getActivityTypeIcon(activity.activity_type)}
|
<ActivityTypeIcon activityType={activity.activity_type} />
|
||||||
<span className="text-sm text-gray-700">{activity.name}</span>
|
<span className="text-sm text-gray-700">{activity.name}</span>
|
||||||
{isDone && (
|
{isDone && (
|
||||||
<span className="ml-auto text-gray-400">
|
<span className="ml-auto text-gray-400">
|
||||||
|
|
@ -129,6 +92,116 @@ function ActivityIndicators(props: Props) {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
ActivityTooltipContent.displayName = 'ActivityTooltipContent';
|
||||||
|
|
||||||
|
function ActivityIndicators(props: Props) {
|
||||||
|
const course = props.course
|
||||||
|
const orgslug = props.orgslug
|
||||||
|
const courseid = props.course_uuid.replace('course_', '')
|
||||||
|
const enableNavigation = props.enableNavigation || false
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
|
||||||
|
const done_activity_style = 'bg-teal-600 hover:bg-teal-700'
|
||||||
|
const black_activity_style = 'bg-zinc-300 hover:bg-zinc-400'
|
||||||
|
const current_activity_style = 'bg-gray-600 animate-pulse hover:bg-gray-700'
|
||||||
|
|
||||||
|
const trail = props.course.trail
|
||||||
|
|
||||||
|
// Flatten all activities for navigation and rendering
|
||||||
|
const allActivities = useMemo(() => {
|
||||||
|
return course.chapters.flatMap((chapter: any) =>
|
||||||
|
chapter.activities.map((activity: any) => ({
|
||||||
|
...activity,
|
||||||
|
chapterId: chapter.id
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}, [course.chapters])
|
||||||
|
|
||||||
|
// Find current activity index
|
||||||
|
const currentActivityIndex = useMemo(() => {
|
||||||
|
if (!props.current_activity) return -1
|
||||||
|
return allActivities.findIndex((activity: any) =>
|
||||||
|
activity.activity_uuid.replace('activity_', '') === props.current_activity
|
||||||
|
)
|
||||||
|
}, [allActivities, props.current_activity])
|
||||||
|
|
||||||
|
// Memoize activity status checks
|
||||||
|
const isActivityDone = useMemo(() => (activity: any) => {
|
||||||
|
let run = props.course.trail?.runs.find(
|
||||||
|
(run: any) => run.course_id == props.course.id
|
||||||
|
)
|
||||||
|
if (run) {
|
||||||
|
return run.steps.find((step: any) => step.activity_id == activity.id)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [props.course]);
|
||||||
|
|
||||||
|
const isActivityCurrent = useMemo(() => (activity: any) => {
|
||||||
|
let activity_uuid = activity.activity_uuid.replace('activity_', '')
|
||||||
|
if (props.current_activity && props.current_activity == activity_uuid) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, [props.current_activity]);
|
||||||
|
|
||||||
|
const getActivityClass = useMemo(() => (activity: any) => {
|
||||||
|
const isCurrent = isActivityCurrent(activity)
|
||||||
|
if (isActivityDone(activity)) {
|
||||||
|
return `${done_activity_style}`
|
||||||
|
}
|
||||||
|
if (isCurrent) {
|
||||||
|
return `${current_activity_style} border-2 border-gray-800 animate-pulse`
|
||||||
|
}
|
||||||
|
return `${black_activity_style}`
|
||||||
|
}, [isActivityDone, isActivityCurrent]);
|
||||||
|
|
||||||
|
const navigateToPrevious = () => {
|
||||||
|
if (currentActivityIndex > 0) {
|
||||||
|
const prevActivity = allActivities[currentActivityIndex - 1]
|
||||||
|
const activityId = prevActivity.activity_uuid.replace('activity_', '')
|
||||||
|
router.push(getUriWithOrg(orgslug, '') + `/course/${courseid}/activity/${activityId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateToNext = () => {
|
||||||
|
if (currentActivityIndex < allActivities.length - 1) {
|
||||||
|
const nextActivity = allActivities[currentActivityIndex + 1]
|
||||||
|
const activityId = nextActivity.activity_uuid.replace('activity_', '')
|
||||||
|
router.push(getUriWithOrg(orgslug, '') + `/course/${courseid}/activity/${activityId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{enableNavigation && (
|
||||||
|
<button
|
||||||
|
onClick={navigateToPrevious}
|
||||||
|
disabled={currentActivityIndex <= 0}
|
||||||
|
className="p-1 rounded-full hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||||
|
aria-label="Previous activity"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={20} className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
{allActivities.map((activity: any) => {
|
||||||
|
const isDone = isActivityDone(activity)
|
||||||
|
const isCurrent = isActivityCurrent(activity)
|
||||||
|
return (
|
||||||
|
<ToolTip
|
||||||
|
sideOffset={8}
|
||||||
|
unstyled
|
||||||
|
content={
|
||||||
|
<ActivityTooltipContent
|
||||||
|
activity={activity}
|
||||||
|
isDone={isDone}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
key={activity.activity_uuid}
|
key={activity.activity_uuid}
|
||||||
>
|
>
|
||||||
|
|
@ -141,22 +214,29 @@ function ActivityIndicators(props: Props) {
|
||||||
''
|
''
|
||||||
)}`
|
)}`
|
||||||
}
|
}
|
||||||
|
className={`${isCurrent ? 'flex-[2]' : 'flex-1'} mx-1`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-[7px] w-auto ${getActivityClass(
|
className={`h-[7px] ${getActivityClass(activity)} rounded-lg transition-all`}
|
||||||
activity
|
|
||||||
)} rounded-lg`}
|
|
||||||
></div>
|
></div>
|
||||||
</Link>
|
</Link>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</React.Fragment>
|
|
||||||
)
|
{enableNavigation && (
|
||||||
})}
|
<button
|
||||||
|
onClick={navigateToNext}
|
||||||
|
disabled={currentActivityIndex >= allActivities.length - 1}
|
||||||
|
className="p-1 rounded-full hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
|
||||||
|
aria-label="Next activity"
|
||||||
|
>
|
||||||
|
<ChevronRight size={20} className="text-gray-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ActivityIndicators
|
export default memo(ActivityIndicators)
|
||||||
|
|
|
||||||
30
apps/web/components/Pages/Courses/CourseBreadcrumbs.tsx
Normal file
30
apps/web/components/Pages/Courses/CourseBreadcrumbs.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Book, ChevronRight } from 'lucide-react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { getUriWithOrg } from '@services/config/config'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface CourseBreadcrumbsProps {
|
||||||
|
course: any
|
||||||
|
orgslug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CourseBreadcrumbs({ course, orgslug }: CourseBreadcrumbsProps) {
|
||||||
|
const cleanCourseUuid = course.course_uuid?.replace('course_', '')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-gray-400 tracking-tight font-medium text-sm flex space-x-1 pt-2">
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<div className="flex space-x-2 items-center">
|
||||||
|
<Book className="text-gray" size={14} />
|
||||||
|
<Link href={getUriWithOrg(orgslug, '') + `/courses`}>
|
||||||
|
Courses
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<div className="first-letter:uppercase">
|
||||||
|
{course.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"@tiptap/core": "^2.11.7",
|
"@tiptap/core": "^2.11.7",
|
||||||
"@tiptap/extension-bullet-list": "^2.11.7",
|
"@tiptap/extension-bullet-list": "^2.11.7",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.11.7",
|
"@tiptap/extension-code-block-lowlight": "^2.11.7",
|
||||||
|
"@tiptap/extension-heading": "^2.12.0",
|
||||||
"@tiptap/extension-link": "^2.11.7",
|
"@tiptap/extension-link": "^2.11.7",
|
||||||
"@tiptap/extension-list-item": "^2.11.7",
|
"@tiptap/extension-list-item": "^2.11.7",
|
||||||
"@tiptap/extension-ordered-list": "^2.11.7",
|
"@tiptap/extension-ordered-list": "^2.11.7",
|
||||||
|
|
@ -63,7 +64,7 @@
|
||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^0.453.0",
|
"lucide-react": "^0.453.0",
|
||||||
"next": "15.3.1",
|
"next": "15.3.2",
|
||||||
"next-auth": "^4.24.11",
|
"next-auth": "^4.24.11",
|
||||||
"nextjs-toploader": "^1.6.12",
|
"nextjs-toploader": "^1.6.12",
|
||||||
"prosemirror-state": "^1.4.3",
|
"prosemirror-state": "^1.4.3",
|
||||||
|
|
|
||||||
113
apps/web/pnpm-lock.yaml
generated
113
apps/web/pnpm-lock.yaml
generated
|
|
@ -84,6 +84,9 @@ importers:
|
||||||
'@tiptap/extension-code-block-lowlight':
|
'@tiptap/extension-code-block-lowlight':
|
||||||
specifier: ^2.11.7
|
specifier: ^2.11.7
|
||||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0)
|
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/extension-code-block@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)(highlight.js@11.11.1)(lowlight@3.3.0)
|
||||||
|
'@tiptap/extension-heading':
|
||||||
|
specifier: ^2.12.0
|
||||||
|
version: 2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||||
'@tiptap/extension-link':
|
'@tiptap/extension-link':
|
||||||
specifier: ^2.11.7
|
specifier: ^2.11.7
|
||||||
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||||
|
|
@ -169,14 +172,14 @@ importers:
|
||||||
specifier: ^0.453.0
|
specifier: ^0.453.0
|
||||||
version: 0.453.0(react@19.0.0)
|
version: 0.453.0(react@19.0.0)
|
||||||
next:
|
next:
|
||||||
specifier: 15.3.1
|
specifier: 15.3.2
|
||||||
version: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
next-auth:
|
next-auth:
|
||||||
specifier: ^4.24.11
|
specifier: ^4.24.11
|
||||||
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)
|
version: 4.24.11(next@15.3.2(@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:
|
nextjs-toploader:
|
||||||
specifier: ^1.6.12
|
specifier: ^1.6.12
|
||||||
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)
|
version: 1.6.12(next@15.3.2(@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:
|
prosemirror-state:
|
||||||
specifier: ^1.4.3
|
specifier: ^1.4.3
|
||||||
version: 1.4.3
|
version: 1.4.3
|
||||||
|
|
@ -624,56 +627,56 @@ packages:
|
||||||
'@napi-rs/wasm-runtime@0.2.8':
|
'@napi-rs/wasm-runtime@0.2.8':
|
||||||
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
|
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
|
||||||
|
|
||||||
'@next/env@15.3.1':
|
'@next/env@15.3.2':
|
||||||
resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==}
|
resolution: {integrity: sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@15.2.1':
|
'@next/eslint-plugin-next@15.2.1':
|
||||||
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
|
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.3.1':
|
'@next/swc-darwin-arm64@15.3.2':
|
||||||
resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==}
|
resolution: {integrity: sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.3.1':
|
'@next/swc-darwin-x64@15.3.2':
|
||||||
resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==}
|
resolution: {integrity: sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [darwin]
|
os: [darwin]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
'@next/swc-linux-arm64-gnu@15.3.2':
|
||||||
resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==}
|
resolution: {integrity: sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.3.1':
|
'@next/swc-linux-arm64-musl@15.3.2':
|
||||||
resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==}
|
resolution: {integrity: sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.3.1':
|
'@next/swc-linux-x64-gnu@15.3.2':
|
||||||
resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==}
|
resolution: {integrity: sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.3.1':
|
'@next/swc-linux-x64-musl@15.3.2':
|
||||||
resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==}
|
resolution: {integrity: sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
'@next/swc-win32-arm64-msvc@15.3.2':
|
||||||
resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==}
|
resolution: {integrity: sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.3.1':
|
'@next/swc-win32-x64-msvc@15.3.2':
|
||||||
resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==}
|
resolution: {integrity: sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA==}
|
||||||
engines: {node: '>= 10'}
|
engines: {node: '>= 10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
@ -1590,8 +1593,8 @@ packages:
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/core': ^2.7.0
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
'@tiptap/extension-heading@2.11.7':
|
'@tiptap/extension-heading@2.12.0':
|
||||||
resolution: {integrity: sha512-8kWh7y4Rd2fwxfWOhFFWncHdkDkMC1Z60yzIZWjIu72+6yQxvo8w3yeb7LI7jER4kffbMmadgcfhCHC/fkObBA==}
|
resolution: {integrity: sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@tiptap/core': ^2.7.0
|
'@tiptap/core': ^2.7.0
|
||||||
|
|
||||||
|
|
@ -2878,8 +2881,8 @@ packages:
|
||||||
nodemailer:
|
nodemailer:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
next@15.3.1:
|
next@15.3.2:
|
||||||
resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==}
|
resolution: {integrity: sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==}
|
||||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -3441,8 +3444,8 @@ packages:
|
||||||
tailwind-merge@2.6.0:
|
tailwind-merge@2.6.0:
|
||||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||||
|
|
||||||
tailwind-merge@3.2.0:
|
tailwind-merge@3.3.0:
|
||||||
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
|
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
|
||||||
|
|
||||||
tailwindcss-animate@1.0.7:
|
tailwindcss-animate@1.0.7:
|
||||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||||
|
|
@ -3926,34 +3929,34 @@ snapshots:
|
||||||
'@tybys/wasm-util': 0.9.0
|
'@tybys/wasm-util': 0.9.0
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/env@15.3.1': {}
|
'@next/env@15.3.2': {}
|
||||||
|
|
||||||
'@next/eslint-plugin-next@15.2.1':
|
'@next/eslint-plugin-next@15.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
|
|
||||||
'@next/swc-darwin-arm64@15.3.1':
|
'@next/swc-darwin-arm64@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-darwin-x64@15.3.1':
|
'@next/swc-darwin-x64@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
'@next/swc-linux-arm64-gnu@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-arm64-musl@15.3.1':
|
'@next/swc-linux-arm64-musl@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-gnu@15.3.1':
|
'@next/swc-linux-x64-gnu@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-linux-x64-musl@15.3.1':
|
'@next/swc-linux-x64-musl@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
'@next/swc-win32-arm64-msvc@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@next/swc-win32-x64-msvc@15.3.1':
|
'@next/swc-win32-x64-msvc@15.3.2':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@nodelib/fs.scandir@2.1.5':
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
|
@ -4860,7 +4863,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||||
|
|
||||||
'@tiptap/extension-heading@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
|
'@tiptap/extension-heading@2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
|
||||||
|
|
||||||
|
|
@ -4980,7 +4983,7 @@ snapshots:
|
||||||
'@tiptap/extension-dropcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
'@tiptap/extension-dropcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||||
'@tiptap/extension-gapcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
'@tiptap/extension-gapcursor': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||||
'@tiptap/extension-hard-break': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
'@tiptap/extension-hard-break': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||||
'@tiptap/extension-heading': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
'@tiptap/extension-heading': 2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||||
'@tiptap/extension-history': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
'@tiptap/extension-history': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||||
'@tiptap/extension-horizontal-rule': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
'@tiptap/extension-horizontal-rule': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
|
||||||
'@tiptap/extension-italic': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
'@tiptap/extension-italic': 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||||
|
|
@ -5499,7 +5502,7 @@ snapshots:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
react-easy-sort: 1.6.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
tailwind-merge: 3.2.0
|
tailwind-merge: 3.3.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
|
|
@ -6311,13 +6314,13 @@ snapshots:
|
||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.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):
|
next-auth@4.24.11(next@15.3.2(@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:
|
dependencies:
|
||||||
'@babel/runtime': 7.27.0
|
'@babel/runtime': 7.27.0
|
||||||
'@panva/hkdf': 1.2.1
|
'@panva/hkdf': 1.2.1
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
jose: 4.15.9
|
jose: 4.15.9
|
||||||
next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
next: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
oauth: 0.9.15
|
oauth: 0.9.15
|
||||||
openid-client: 5.7.1
|
openid-client: 5.7.1
|
||||||
preact: 10.26.5
|
preact: 10.26.5
|
||||||
|
|
@ -6326,9 +6329,9 @@ snapshots:
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
uuid: 8.3.2
|
uuid: 8.3.2
|
||||||
|
|
||||||
next@15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@next/env': 15.3.1
|
'@next/env': 15.3.2
|
||||||
'@swc/counter': 0.1.3
|
'@swc/counter': 0.1.3
|
||||||
'@swc/helpers': 0.5.15
|
'@swc/helpers': 0.5.15
|
||||||
busboy: 1.6.0
|
busboy: 1.6.0
|
||||||
|
|
@ -6338,23 +6341,23 @@ snapshots:
|
||||||
react-dom: 19.0.0(react@19.0.0)
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
styled-jsx: 5.1.6(react@19.0.0)
|
styled-jsx: 5.1.6(react@19.0.0)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@next/swc-darwin-arm64': 15.3.1
|
'@next/swc-darwin-arm64': 15.3.2
|
||||||
'@next/swc-darwin-x64': 15.3.1
|
'@next/swc-darwin-x64': 15.3.2
|
||||||
'@next/swc-linux-arm64-gnu': 15.3.1
|
'@next/swc-linux-arm64-gnu': 15.3.2
|
||||||
'@next/swc-linux-arm64-musl': 15.3.1
|
'@next/swc-linux-arm64-musl': 15.3.2
|
||||||
'@next/swc-linux-x64-gnu': 15.3.1
|
'@next/swc-linux-x64-gnu': 15.3.2
|
||||||
'@next/swc-linux-x64-musl': 15.3.1
|
'@next/swc-linux-x64-musl': 15.3.2
|
||||||
'@next/swc-win32-arm64-msvc': 15.3.1
|
'@next/swc-win32-arm64-msvc': 15.3.2
|
||||||
'@next/swc-win32-x64-msvc': 15.3.1
|
'@next/swc-win32-x64-msvc': 15.3.2
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
sharp: 0.34.1
|
sharp: 0.34.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- babel-plugin-macros
|
- babel-plugin-macros
|
||||||
|
|
||||||
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):
|
nextjs-toploader@1.6.12(next@15.3.2(@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:
|
dependencies:
|
||||||
next: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
next: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
nprogress: 0.2.0
|
nprogress: 0.2.0
|
||||||
prop-types: 15.8.1
|
prop-types: 15.8.1
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
|
|
@ -7021,7 +7024,7 @@ snapshots:
|
||||||
|
|
||||||
tailwind-merge@2.6.0: {}
|
tailwind-merge@2.6.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.2.0: {}
|
tailwind-merge@3.3.0: {}
|
||||||
|
|
||||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
|
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue