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
|
||||
echo=False,
|
||||
pool_pre_ping=True, # type: ignore
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_size=5,
|
||||
max_overflow=0,
|
||||
pool_recycle=300, # Recycle connections after 5 minutes
|
||||
pool_timeout=30
|
||||
)
|
||||
|
|
|
|||
|
|
@ -133,42 +133,40 @@ async def get_course_meta(
|
|||
# Avoid circular import
|
||||
from src.services.courses.chapters import get_course_chapters
|
||||
|
||||
# Get course with a single query
|
||||
course_statement = select(Course).where(Course.course_uuid == course_uuid)
|
||||
course = db_session.exec(course_statement).first()
|
||||
# Get course with authors in a single query using joins
|
||||
course_statement = (
|
||||
select(Course, ResourceAuthor, User)
|
||||
.outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore
|
||||
.outerjoin(User, ResourceAuthor.user_id == User.id) # type: ignore
|
||||
.where(Course.course_uuid == course_uuid)
|
||||
.order_by(ResourceAuthor.id.asc()) # type: ignore
|
||||
)
|
||||
results = db_session.exec(course_statement).all()
|
||||
|
||||
if not course:
|
||||
if not results:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Course not found",
|
||||
)
|
||||
|
||||
# Extract course and authors from results
|
||||
course = results[0][0] # First result's Course
|
||||
author_results = [(ra, u) for _, ra, u in results if ra is not None and u is not None]
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
||||
|
||||
# Start async tasks concurrently
|
||||
tasks = []
|
||||
|
||||
# Task 1: Get course authors with their roles
|
||||
async def get_authors():
|
||||
authors_statement = (
|
||||
select(ResourceAuthor, User)
|
||||
.join(User, ResourceAuthor.user_id == User.id) # type: ignore
|
||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
||||
.order_by(
|
||||
ResourceAuthor.id.asc() # type: ignore
|
||||
)
|
||||
)
|
||||
return db_session.exec(authors_statement).all()
|
||||
|
||||
# Task 2: Get course chapters
|
||||
# Task 1: Get course chapters
|
||||
async def get_chapters():
|
||||
# Ensure course.id is not None
|
||||
if course.id is None:
|
||||
return []
|
||||
return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities)
|
||||
|
||||
# Task 3: Get user trail (only for authenticated users)
|
||||
# Task 2: Get user trail (only for authenticated users)
|
||||
async def get_trail():
|
||||
if isinstance(current_user, AnonymousUser):
|
||||
return None
|
||||
|
|
@ -177,12 +175,11 @@ async def get_course_meta(
|
|||
)
|
||||
|
||||
# Add tasks to the list
|
||||
tasks.append(get_authors())
|
||||
tasks.append(get_chapters())
|
||||
tasks.append(get_trail())
|
||||
|
||||
# Run all tasks concurrently
|
||||
author_results, chapters, trail = await asyncio.gather(*tasks)
|
||||
chapters, trail = await asyncio.gather(*tasks)
|
||||
|
||||
# Convert to AuthorWithRole objects
|
||||
authors = [
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
|
|||
// Get Org context information
|
||||
const course_meta = await getCourseMetadata(
|
||||
params.courseid,
|
||||
{ revalidate: 0, tags: ['courses'] },
|
||||
{ revalidate: 30, tags: ['courses'] },
|
||||
access_token ? access_token : null
|
||||
)
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ const EditActivity = async (params: any) => {
|
|||
const courseid = (await params.params).courseid
|
||||
const courseInfo = await getCourseMetadata(
|
||||
courseid,
|
||||
{ revalidate: 0, tags: ['courses'] },
|
||||
{ revalidate: 30, tags: ['courses'] },
|
||||
access_token ? access_token : null
|
||||
)
|
||||
const activity = await getActivityWithAuthHeader(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,7 @@ type Session = {
|
|||
async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) {
|
||||
return await getCourseMetadata(
|
||||
courseuuid,
|
||||
{ revalidate: 0, tags: ['courses'] },
|
||||
{ revalidate: 30, tags: ['courses'] },
|
||||
access_token || null
|
||||
)
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ const ActivityPage = async (params: any) => {
|
|||
fetchCourseMetadata(courseuuid, access_token),
|
||||
getActivityWithAuthHeader(
|
||||
activityid,
|
||||
{ revalidate: 0, tags: ['activities'] },
|
||||
{ revalidate: 60, tags: ['activities'] },
|
||||
access_token || null
|
||||
)
|
||||
])
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { useMediaQuery } from 'usehooks-ts'
|
|||
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
|
||||
import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile'
|
||||
import CourseAuthors from '@components/Objects/Courses/CourseAuthors/CourseAuthors'
|
||||
import CourseBreadcrumbs from '@components/Pages/Courses/CourseBreadcrumbs'
|
||||
|
||||
const CourseClient = (props: any) => {
|
||||
const [learnings, setLearnings] = useState<any>([])
|
||||
|
|
@ -127,7 +128,11 @@ const CourseClient = (props: any) => {
|
|||
) : (
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export async function generateMetadata(props: MetadataProps): Promise<Metadata>
|
|||
})
|
||||
const course_meta = await getCourseMetadata(
|
||||
params.courseuuid,
|
||||
{ revalidate: 1800, tags: ['courses'] },
|
||||
{ revalidate: 30, tags: ['courses'] },
|
||||
access_token ? access_token : null
|
||||
)
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ const CoursePage = async (params: any) => {
|
|||
// Fetch course metadata once
|
||||
const course_meta = await getCourseMetadata(
|
||||
params.params.courseuuid,
|
||||
{ revalidate: 0, tags: ['courses'] },
|
||||
{ revalidate: 30, tags: ['courses'] },
|
||||
access_token ? access_token : null
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 UserBlock from '@components/Objects/Editor/Extensions/Users/UserBlock'
|
||||
import { getLinkExtension } from '@components/Objects/Editor/EditorConf'
|
||||
import TableOfContents from './TableOfContents'
|
||||
import { CustomHeading } from './CustomHeadingExtenstion'
|
||||
|
||||
interface Editor {
|
||||
content: string
|
||||
activity: any
|
||||
}
|
||||
|
||||
|
||||
|
||||
function Canva(props: Editor) {
|
||||
/**
|
||||
* Important Note : This is a workaround to enable user interaction features to be implemented easily, like text selection, AI features and other planned features, this is set to true but otherwise it should be set to false.
|
||||
|
|
@ -59,6 +63,7 @@ function Canva(props: Editor) {
|
|||
editable: isEditable,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: 'bullet-list',
|
||||
|
|
@ -70,6 +75,7 @@ function Canva(props: Editor) {
|
|||
},
|
||||
},
|
||||
}),
|
||||
CustomHeading,
|
||||
NoTextInput,
|
||||
// Custom Extensions
|
||||
InfoCallout.configure({
|
||||
|
|
@ -137,7 +143,10 @@ function Canva(props: Editor) {
|
|||
<EditorOptionsProvider options={{ isEditable: false }}>
|
||||
<CanvaWrapper>
|
||||
<AICanvaToolkit activity={props.activity} editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
<ContentWrapper>
|
||||
<TableOfContents editor={editor} />
|
||||
<EditorContent editor={editor} />
|
||||
</ContentWrapper>
|
||||
</CanvaWrapper>
|
||||
</EditorOptionsProvider>
|
||||
)
|
||||
|
|
@ -146,33 +155,17 @@ function Canva(props: Editor) {
|
|||
const CanvaWrapper = styled.div`
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
`
|
||||
|
||||
.bubble-menu {
|
||||
display: flex;
|
||||
background-color: #0d0d0d;
|
||||
padding: 0.2rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
padding: 0 0.2rem;
|
||||
opacity: 0.6;
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// disable chrome outline
|
||||
const ContentWrapper = styled.div`
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.ProseMirror {
|
||||
// Workaround to disable editor from being edited by the user.
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
// disable chrome outline
|
||||
caret-color: transparent;
|
||||
|
||||
h1 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
// Redirect to the first activity
|
||||
await revalidateTags(['activities'], orgslug)
|
||||
router.push(
|
||||
getUriWithOrg(orgslug, '') +
|
||||
`/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}`
|
||||
|
|
@ -209,6 +210,7 @@ const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobil
|
|||
console.error('Failed to perform course action:', error)
|
||||
} finally {
|
||||
setIsActionLoading(false)
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEditorProvider } from '@components/Contexts/Editor/EditorContext'
|
||||
import { NodeViewContent, NodeViewWrapper } from '@tiptap/react'
|
||||
import { AlertCircle, X } from 'lucide-react'
|
||||
import { AlertCircle, Info, X } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
|
|
@ -100,11 +100,11 @@ function InfoCalloutComponent(props: any) {
|
|||
const getVariantClasses = () => {
|
||||
switch(options.variant) {
|
||||
case 'filled':
|
||||
return 'bg-blue-500 text-white';
|
||||
return 'bg-gray-300 text-gray-700';
|
||||
case 'outlined':
|
||||
return 'bg-transparent border-2 border-blue-500 text-blue-700';
|
||||
return 'bg-transparent border-2 border-gray-300 text-gray-500';
|
||||
default:
|
||||
return 'bg-blue-200 text-blue-900';
|
||||
return 'bg-gray-100 text-gray-600';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,12 +119,12 @@ function InfoCalloutComponent(props: any) {
|
|||
return (
|
||||
<NodeViewWrapper>
|
||||
<InfoCalloutWrapper
|
||||
className={`flex items-center rounded-lg shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
|
||||
className={`flex items-center rounded-xl shadow-inner ${getVariantClasses()} ${getSizeClasses()}`}
|
||||
contentEditable={isEditable}
|
||||
size={options.size}
|
||||
>
|
||||
<IconWrapper size={options.size}>
|
||||
<AlertCircle />
|
||||
<Info />
|
||||
</IconWrapper>
|
||||
<ContentWrapper className="grow">
|
||||
<NodeViewContent contentEditable={isEditable} className="content" />
|
||||
|
|
|
|||
|
|
@ -1,64 +1,41 @@
|
|||
'use client'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
const variants = {
|
||||
hidden: { opacity: 0, x: 0, y: 0 },
|
||||
enter: { opacity: 1, x: 0, y: 0 },
|
||||
exit: { opacity: 0, x: 0, y: 0 },
|
||||
}
|
||||
|
||||
// Animation variants for the dots
|
||||
const dotVariants = {
|
||||
initial: { scale: 0.8, opacity: 0.4 },
|
||||
animate: (i: number) => ({
|
||||
scale: [0.8, 1.2, 0.8],
|
||||
opacity: [0.4, 1, 0.4],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
ease: "easeInOut"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function PageLoading() {
|
||||
return (
|
||||
<motion.main
|
||||
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
|
||||
key={i}
|
||||
custom={i}
|
||||
variants={dotVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="w-4 h-4 rounded-full bg-gray-500 dark:bg-gray-400"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
className="mt-6 text-sm text-gray-500 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
Loading...
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.main>
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: [0, 0.5, 1],
|
||||
scale: 1,
|
||||
transition: {
|
||||
duration: 0.8,
|
||||
scale: {
|
||||
type: "spring",
|
||||
stiffness: 50,
|
||||
damping: 15,
|
||||
delay: 0.2
|
||||
},
|
||||
opacity: {
|
||||
duration: 0.6,
|
||||
times: [0, 0.6, 1]
|
||||
}
|
||||
}
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Loader2 className="w-10 h-10 text-gray-400 animate-spin" />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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}>
|
||||
<button
|
||||
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"
|
||||
title="View all activities"
|
||||
>
|
||||
<ListTree size={16} className="text-gray-700" />
|
||||
<ListTree size={17} />
|
||||
<span className="text-xs font-bold">Chapters</span>
|
||||
</button>
|
||||
|
||||
{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">
|
||||
<h3 className="text-sm font-semibold text-gray-800">Course Content</h3>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import React, { useEffect, useState, useRef, useMemo, memo } from 'react'
|
||||
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
||||
import ActivityChapterDropdown from './ActivityChapterDropdown'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
|
@ -15,6 +15,86 @@ interface FixedActivitySecondaryBarProps {
|
|||
activity: any
|
||||
}
|
||||
|
||||
// Memoized navigation buttons component
|
||||
const NavigationButtons = memo(({
|
||||
prevActivity,
|
||||
nextActivity,
|
||||
currentIndex,
|
||||
allActivities,
|
||||
navigateToActivity
|
||||
}: {
|
||||
prevActivity: any,
|
||||
nextActivity: any,
|
||||
currentIndex: number,
|
||||
allActivities: any[],
|
||||
navigateToActivity: (activity: any) => void
|
||||
}) => (
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
<button
|
||||
onClick={() => navigateToActivity(prevActivity)}
|
||||
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200 ${
|
||||
prevActivity
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!prevActivity}
|
||||
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
|
||||
>
|
||||
<ChevronLeft size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
||||
<div className="flex flex-col items-start hidden sm:flex">
|
||||
<span className="text-xs text-gray-500">Previous</span>
|
||||
<span className="text-sm font-medium text-left truncate max-w-[100px] sm:max-w-[150px]">
|
||||
{prevActivity ? prevActivity.name : 'No previous activity'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-500 px-1 sm:px-2">
|
||||
{currentIndex + 1} of {allActivities.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => navigateToActivity(nextActivity)}
|
||||
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200`}
|
||||
disabled={!nextActivity}
|
||||
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
|
||||
>
|
||||
<div className="flex flex-col items-end hidden sm:flex">
|
||||
<span className={`text-xs ${nextActivity ? 'text-gray-500' : 'text-gray-500'}`}>Next</span>
|
||||
<span className="text-sm font-medium text-right truncate max-w-[100px] sm:max-w-[150px]">
|
||||
{nextActivity ? nextActivity.name : 'No next activity'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</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);
|
||||
|
|
@ -22,12 +102,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
|||
const mainActivityInfoRef = useRef<HTMLDivElement | null>(null);
|
||||
const org = useOrg() as any;
|
||||
|
||||
// Function to find the current activity's position in the course
|
||||
const findActivityPosition = () => {
|
||||
// Memoize activity position calculation
|
||||
const { allActivities, currentIndex } = useMemo(() => {
|
||||
let allActivities: any[] = [];
|
||||
let currentIndex = -1;
|
||||
|
||||
// Flatten all activities from all chapters
|
||||
props.course.chapters.forEach((chapter: any) => {
|
||||
chapter.activities.forEach((activity: any) => {
|
||||
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
|
||||
|
|
@ -37,7 +116,6 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
|||
chapterName: chapter.name
|
||||
});
|
||||
|
||||
// Check if this is the current activity
|
||||
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
|
||||
currentIndex = allActivities.length - 1;
|
||||
}
|
||||
|
|
@ -45,15 +123,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
|||
});
|
||||
|
||||
return { allActivities, currentIndex };
|
||||
};
|
||||
}, [props.course, props.currentActivityId]);
|
||||
|
||||
const { allActivities, currentIndex } = findActivityPosition();
|
||||
|
||||
// Get previous and next activities
|
||||
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
|
||||
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
|
||||
|
||||
// Navigate to an activity
|
||||
const navigateToActivity = (activity: any) => {
|
||||
if (!activity) return;
|
||||
|
||||
|
|
@ -61,32 +135,26 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
|||
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
|
||||
};
|
||||
|
||||
// Handle scroll and intersection observer
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
};
|
||||
|
||||
// Set up intersection observer for the main activity info
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Show the fixed bar when the main info is not visible
|
||||
setShouldShow(!entry.isIntersecting);
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.1, 1],
|
||||
rootMargin: '-80px 0px 0px 0px' // Increased margin to account for the header
|
||||
rootMargin: '-80px 0px 0px 0px'
|
||||
}
|
||||
);
|
||||
|
||||
// Start observing the main activity info section with a slight delay to ensure DOM is ready
|
||||
setTimeout(() => {
|
||||
const mainActivityInfo = document.querySelector('.activity-info-section');
|
||||
if (mainActivityInfo) {
|
||||
mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement;
|
||||
observer.observe(mainActivityInfo);
|
||||
}
|
||||
}, 100);
|
||||
const mainActivityInfo = document.querySelector('.activity-info-section');
|
||||
if (mainActivityInfo) {
|
||||
mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement;
|
||||
observer.observe(mainActivityInfo);
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
|
||||
|
|
@ -98,86 +166,29 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
|
|||
};
|
||||
}, []);
|
||||
|
||||
if (!shouldShow) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldShow && (
|
||||
<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>
|
||||
<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} />
|
||||
|
||||
{/* Right Section - Navigation Controls */}
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
<button
|
||||
onClick={() => navigateToActivity(prevActivity)}
|
||||
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200 ${
|
||||
prevActivity
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 cursor-not-allowed'
|
||||
}`}
|
||||
disabled={!prevActivity}
|
||||
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
|
||||
>
|
||||
<ChevronLeft size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
||||
<div className="flex flex-col items-start hidden sm:flex">
|
||||
<span className="text-xs text-gray-500">Previous</span>
|
||||
<span className="text-sm font-medium text-left truncate max-w-[100px] sm:max-w-[150px]">
|
||||
{prevActivity ? prevActivity.name : 'No previous activity'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-medium text-gray-500 px-1 sm:px-2">
|
||||
{currentIndex + 1} of {allActivities.length}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => navigateToActivity(nextActivity)}
|
||||
className={`flex items-center space-x-1 sm:space-x-2 py-1.5 px-1.5 sm:px-2 rounded-md transition-all duration-200`}
|
||||
disabled={!nextActivity}
|
||||
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
|
||||
>
|
||||
<div className="flex flex-col items-end hidden sm:flex">
|
||||
<span className={`text-xs ${nextActivity ? 'text-gray-500' : 'text-gray-500'}`}>Next</span>
|
||||
<span className="text-sm font-medium text-right truncate max-w-[100px] sm:max-w-[150px]">
|
||||
{nextActivity ? nextActivity.name : 'No next activity'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="shrink-0 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center flex-shrink-0">
|
||||
<NavigationButtons
|
||||
prevActivity={prevActivity}
|
||||
nextActivity={nextActivity}
|
||||
currentIndex={currentIndex}
|
||||
allActivities={allActivities}
|
||||
navigateToActivity={navigateToActivity}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +1,109 @@
|
|||
'use client'
|
||||
import { BookOpenCheck, Check, FileText, Layers, Video, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import React, { useMemo, memo, useState } from 'react'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import { Video, FileText, Layers, BookOpenCheck, Check } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface Props {
|
||||
course: any
|
||||
orgslug: string
|
||||
course_uuid: string
|
||||
current_activity?: any
|
||||
current_activity?: string
|
||||
enableNavigation?: boolean
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getActivityTypeLabel(activityType: string): string {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return 'Video'
|
||||
case 'TYPE_DOCUMENT':
|
||||
return 'Document'
|
||||
case 'TYPE_DYNAMIC':
|
||||
return 'Interactive'
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return 'Assignment'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function getActivityTypeBadgeColor(activityType: string): string {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return 'bg-blue-100 text-blue-700'
|
||||
case 'TYPE_DOCUMENT':
|
||||
return 'bg-purple-100 text-purple-700'
|
||||
case 'TYPE_DYNAMIC':
|
||||
return 'bg-green-100 text-green-700'
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return 'bg-orange-100 text-orange-700'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
// Memoized activity type icon component
|
||||
const ActivityTypeIcon = memo(({ activityType }: { activityType: string }) => {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return <Video size={16} className="text-gray-400" />
|
||||
case 'TYPE_DOCUMENT':
|
||||
return <FileText size={16} className="text-gray-400" />
|
||||
case 'TYPE_DYNAMIC':
|
||||
return <Layers size={16} className="text-gray-400" />
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return <BookOpenCheck size={16} className="text-gray-400" />
|
||||
default:
|
||||
return <FileText size={16} className="text-gray-400" />
|
||||
}
|
||||
});
|
||||
|
||||
ActivityTypeIcon.displayName = 'ActivityTypeIcon';
|
||||
|
||||
// Memoized activity tooltip content
|
||||
const ActivityTooltipContent = memo(({
|
||||
activity,
|
||||
isDone,
|
||||
isCurrent
|
||||
}: {
|
||||
activity: any,
|
||||
isDone: boolean,
|
||||
isCurrent: boolean
|
||||
}) => (
|
||||
<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">
|
||||
<ActivityTypeIcon activityType={activity.activity_type} />
|
||||
<span className="text-sm text-gray-700">{activity.name}</span>
|
||||
{isDone && (
|
||||
<span className="ml-auto text-gray-400">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getActivityTypeBadgeColor(activity.activity_type)}`}>
|
||||
{getActivityTypeLabel(activity.activity_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isCurrent ? 'Current Activity' : isDone ? 'Completed' : 'Not Started'}
|
||||
</span>
|
||||
</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'
|
||||
|
|
@ -22,141 +111,132 @@ function ActivityIndicators(props: Props) {
|
|||
|
||||
const trail = props.course.trail
|
||||
|
||||
function isActivityDone(activity: any) {
|
||||
// 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)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, [props.course]);
|
||||
|
||||
function isActivityCurrent(activity: any) {
|
||||
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]);
|
||||
|
||||
function getActivityClass(activity: any) {
|
||||
const getActivityClass = useMemo(() => (activity: any) => {
|
||||
const isCurrent = isActivityCurrent(activity)
|
||||
if (isActivityDone(activity)) {
|
||||
return done_activity_style
|
||||
return `${done_activity_style}`
|
||||
}
|
||||
if (isActivityCurrent(activity)) {
|
||||
return current_activity_style
|
||||
if (isCurrent) {
|
||||
return `${current_activity_style} border-2 border-gray-800 animate-pulse`
|
||||
}
|
||||
return black_activity_style
|
||||
}
|
||||
return `${black_activity_style}`
|
||||
}, [isActivityDone, isActivityCurrent]);
|
||||
|
||||
const getActivityTypeIcon = (activityType: string) => {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return <Video size={16} className="text-gray-400" />
|
||||
case 'TYPE_DOCUMENT':
|
||||
return <FileText size={16} className="text-gray-400" />
|
||||
case 'TYPE_DYNAMIC':
|
||||
return <Layers size={16} className="text-gray-400" />
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return <BookOpenCheck size={16} className="text-gray-400" />
|
||||
default:
|
||||
return <FileText size={16} className="text-gray-400" />
|
||||
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 getActivityTypeLabel = (activityType: string) => {
|
||||
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) => {
|
||||
switch (activityType) {
|
||||
case 'TYPE_VIDEO':
|
||||
return 'bg-gray-100 text-gray-700 font-bold'
|
||||
case 'TYPE_DOCUMENT':
|
||||
return 'bg-gray-100 text-gray-700 font-bold'
|
||||
case 'TYPE_DYNAMIC':
|
||||
return 'bg-gray-100 text-gray-700 font-bold'
|
||||
case 'TYPE_ASSIGNMENT':
|
||||
return 'bg-gray-100 text-gray-700 font-bold'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 font-bold'
|
||||
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="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="flex items-center gap-2">
|
||||
{getActivityTypeIcon(activity.activity_type)}
|
||||
<span className="text-sm text-gray-700">{activity.name}</span>
|
||||
{isDone && (
|
||||
<span className="ml-auto text-gray-400">
|
||||
<Check size={14} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getActivityTypeBadgeColor(activity.activity_type)}`}>
|
||||
{getActivityTypeLabel(activity.activity_type)}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{isCurrent ? 'Current Activity' : isDone ? 'Completed' : 'Not Started'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
key={activity.activity_uuid}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={
|
||||
getUriWithOrg(orgslug, '') +
|
||||
`/course/${courseid}/activity/${activity.activity_uuid.replace(
|
||||
'activity_',
|
||||
''
|
||||
)}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={`h-[7px] w-auto ${getActivityClass(
|
||||
activity
|
||||
)} rounded-lg`}
|
||||
></div>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
<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}
|
||||
>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={
|
||||
getUriWithOrg(orgslug, '') +
|
||||
`/course/${courseid}/activity/${activity.activity_uuid.replace(
|
||||
'activity_',
|
||||
''
|
||||
)}`
|
||||
}
|
||||
className={`${isCurrent ? 'flex-[2]' : 'flex-1'} mx-1`}
|
||||
>
|
||||
<div
|
||||
className={`h-[7px] ${getActivityClass(activity)} rounded-lg transition-all`}
|
||||
></div>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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/extension-bullet-list": "^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-list-item": "^2.11.7",
|
||||
"@tiptap/extension-ordered-list": "^2.11.7",
|
||||
|
|
@ -63,7 +64,7 @@
|
|||
"katex": "^0.16.21",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.453.0",
|
||||
"next": "15.3.1",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"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':
|
||||
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)
|
||||
'@tiptap/extension-heading':
|
||||
specifier: ^2.12.0
|
||||
version: 2.12.0(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
|
||||
'@tiptap/extension-link':
|
||||
specifier: ^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
|
||||
version: 0.453.0(react@19.0.0)
|
||||
next:
|
||||
specifier: 15.3.1
|
||||
version: 15.3.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
specifier: 15.3.2
|
||||
version: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next-auth:
|
||||
specifier: ^4.24.11
|
||||
version: 4.24.11(next@15.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:
|
||||
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:
|
||||
specifier: ^1.4.3
|
||||
version: 1.4.3
|
||||
|
|
@ -624,56 +627,56 @@ packages:
|
|||
'@napi-rs/wasm-runtime@0.2.8':
|
||||
resolution: {integrity: sha512-OBlgKdX7gin7OIq4fadsjpg+cp2ZphvAIKucHsNfTdJiqdOmOEwQd/bHi0VwNrcw5xpBJyUw6cK/QilCqy1BSg==}
|
||||
|
||||
'@next/env@15.3.1':
|
||||
resolution: {integrity: sha512-cwK27QdzrMblHSn9DZRV+DQscHXRuJv6MydlJRpFSqJWZrTYMLzKDeyueJNN9MGd8NNiUKzDQADAf+dMLXX7YQ==}
|
||||
'@next/env@15.3.2':
|
||||
resolution: {integrity: sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==}
|
||||
|
||||
'@next/eslint-plugin-next@15.2.1':
|
||||
resolution: {integrity: sha512-6ppeToFd02z38SllzWxayLxjjNfzvc7Wm07gQOKSLjyASvKcXjNStZrLXMHuaWkhjqxe+cnhb2uzfWXm1VEj/Q==}
|
||||
|
||||
'@next/swc-darwin-arm64@15.3.1':
|
||||
resolution: {integrity: sha512-hjDw4f4/nla+6wysBL07z52Gs55Gttp5Bsk5/8AncQLJoisvTBP0pRIBK/B16/KqQyH+uN4Ww8KkcAqJODYH3w==}
|
||||
'@next/swc-darwin-arm64@15.3.2':
|
||||
resolution: {integrity: sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@15.3.1':
|
||||
resolution: {integrity: sha512-q+aw+cJ2ooVYdCEqZVk+T4Ni10jF6Fo5DfpEV51OupMaV5XL6pf3GCzrk6kSSZBsMKZtVC1Zm/xaNBFpA6bJ2g==}
|
||||
'@next/swc-darwin-x64@15.3.2':
|
||||
resolution: {integrity: sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
||||
resolution: {integrity: sha512-wBQ+jGUI3N0QZyWmmvRHjXjTWFy8o+zPFLSOyAyGFI94oJi+kK/LIZFJXeykvgXUk1NLDAEFDZw/NVINhdk9FQ==}
|
||||
'@next/swc-linux-arm64-gnu@15.3.2':
|
||||
resolution: {integrity: sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.1':
|
||||
resolution: {integrity: sha512-IIxXEXRti/AulO9lWRHiCpUUR8AR/ZYLPALgiIg/9ENzMzLn3l0NSxVdva7R/VDcuSEBo0eGVCe3evSIHNz0Hg==}
|
||||
'@next/swc-linux-arm64-musl@15.3.2':
|
||||
resolution: {integrity: sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.1':
|
||||
resolution: {integrity: sha512-bfI4AMhySJbyXQIKH5rmLJ5/BP7bPwuxauTvVEiJ/ADoddaA9fgyNNCcsbu9SlqfHDoZmfI6g2EjzLwbsVTr5A==}
|
||||
'@next/swc-linux-x64-gnu@15.3.2':
|
||||
resolution: {integrity: sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.1':
|
||||
resolution: {integrity: sha512-FeAbR7FYMWR+Z+M5iSGytVryKHiAsc0x3Nc3J+FD5NVbD5Mqz7fTSy8CYliXinn7T26nDMbpExRUI/4ekTvoiA==}
|
||||
'@next/swc-linux-x64-musl@15.3.2':
|
||||
resolution: {integrity: sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
||||
resolution: {integrity: sha512-yP7FueWjphQEPpJQ2oKmshk/ppOt+0/bB8JC8svPUZNy0Pi3KbPx2Llkzv1p8CoQa+D2wknINlJpHf3vtChVBw==}
|
||||
'@next/swc-win32-arm64-msvc@15.3.2':
|
||||
resolution: {integrity: sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.1':
|
||||
resolution: {integrity: sha512-3PMvF2zRJAifcRNni9uMk/gulWfWS+qVI/pagd+4yLF5bcXPZPPH2xlYRYOsUjmCJOXSTAC2PjRzbhsRzR2fDQ==}
|
||||
'@next/swc-win32-x64-msvc@15.3.2':
|
||||
resolution: {integrity: sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
|
@ -1590,8 +1593,8 @@ packages:
|
|||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
'@tiptap/extension-heading@2.11.7':
|
||||
resolution: {integrity: sha512-8kWh7y4Rd2fwxfWOhFFWncHdkDkMC1Z60yzIZWjIu72+6yQxvo8w3yeb7LI7jER4kffbMmadgcfhCHC/fkObBA==}
|
||||
'@tiptap/extension-heading@2.12.0':
|
||||
resolution: {integrity: sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.7.0
|
||||
|
||||
|
|
@ -2878,8 +2881,8 @@ packages:
|
|||
nodemailer:
|
||||
optional: true
|
||||
|
||||
next@15.3.1:
|
||||
resolution: {integrity: sha512-8+dDV0xNLOgHlyBxP1GwHGVaNXsmp+2NhZEYrXr24GWLHtt27YrBPbPuHvzlhi7kZNYjeJNR93IF5zfFu5UL0g==}
|
||||
next@15.3.2:
|
||||
resolution: {integrity: sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==}
|
||||
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
|
|
@ -3441,8 +3444,8 @@ packages:
|
|||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwind-merge@3.2.0:
|
||||
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
|
||||
tailwind-merge@3.3.0:
|
||||
resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==}
|
||||
|
||||
tailwindcss-animate@1.0.7:
|
||||
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
|
||||
|
|
@ -3926,34 +3929,34 @@ snapshots:
|
|||
'@tybys/wasm-util': 0.9.0
|
||||
optional: true
|
||||
|
||||
'@next/env@15.3.1': {}
|
||||
'@next/env@15.3.2': {}
|
||||
|
||||
'@next/eslint-plugin-next@15.2.1':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@15.3.1':
|
||||
'@next/swc-darwin-arm64@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@15.3.1':
|
||||
'@next/swc-darwin-x64@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@15.3.1':
|
||||
'@next/swc-linux-arm64-gnu@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@15.3.1':
|
||||
'@next/swc-linux-arm64-musl@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@15.3.1':
|
||||
'@next/swc-linux-x64-gnu@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@15.3.1':
|
||||
'@next/swc-linux-x64-musl@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@15.3.1':
|
||||
'@next/swc-win32-arm64-msvc@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@15.3.1':
|
||||
'@next/swc-win32-x64-msvc@15.3.2':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
|
|
@ -4860,7 +4863,7 @@ snapshots:
|
|||
dependencies:
|
||||
'@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:
|
||||
'@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-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-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-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))
|
||||
|
|
@ -5499,7 +5502,7 @@ snapshots:
|
|||
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)
|
||||
tailwind-merge: 3.2.0
|
||||
tailwind-merge: 3.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
|
@ -6311,13 +6314,13 @@ snapshots:
|
|||
|
||||
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:
|
||||
'@babel/runtime': 7.27.0
|
||||
'@panva/hkdf': 1.2.1
|
||||
cookie: 0.7.2
|
||||
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
|
||||
openid-client: 5.7.1
|
||||
preact: 10.26.5
|
||||
|
|
@ -6326,9 +6329,9 @@ snapshots:
|
|||
react-dom: 19.0.0(react@19.0.0)
|
||||
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:
|
||||
'@next/env': 15.3.1
|
||||
'@next/env': 15.3.2
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/helpers': 0.5.15
|
||||
busboy: 1.6.0
|
||||
|
|
@ -6338,23 +6341,23 @@ snapshots:
|
|||
react-dom: 19.0.0(react@19.0.0)
|
||||
styled-jsx: 5.1.6(react@19.0.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.3.1
|
||||
'@next/swc-darwin-x64': 15.3.1
|
||||
'@next/swc-linux-arm64-gnu': 15.3.1
|
||||
'@next/swc-linux-arm64-musl': 15.3.1
|
||||
'@next/swc-linux-x64-gnu': 15.3.1
|
||||
'@next/swc-linux-x64-musl': 15.3.1
|
||||
'@next/swc-win32-arm64-msvc': 15.3.1
|
||||
'@next/swc-win32-x64-msvc': 15.3.1
|
||||
'@next/swc-darwin-arm64': 15.3.2
|
||||
'@next/swc-darwin-x64': 15.3.2
|
||||
'@next/swc-linux-arm64-gnu': 15.3.2
|
||||
'@next/swc-linux-arm64-musl': 15.3.2
|
||||
'@next/swc-linux-x64-gnu': 15.3.2
|
||||
'@next/swc-linux-x64-musl': 15.3.2
|
||||
'@next/swc-win32-arm64-msvc': 15.3.2
|
||||
'@next/swc-win32-x64-msvc': 15.3.2
|
||||
'@opentelemetry/api': 1.9.0
|
||||
sharp: 0.34.1
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- 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:
|
||||
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
|
||||
prop-types: 15.8.1
|
||||
react: 19.0.0
|
||||
|
|
@ -7021,7 +7024,7 @@ snapshots:
|
|||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwind-merge@3.2.0: {}
|
||||
tailwind-merge@3.3.0: {}
|
||||
|
||||
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
|
||||
dependencies:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue