Merge pull request #480 from learnhouse/feat/more-ux-upgrades

More UX changes
This commit is contained in:
Badr B. 2025-05-25 22:07:36 +02:00 committed by GitHub
commit 66c6ea8779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1385 additions and 841 deletions

View file

@ -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
) )

View file

@ -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 = [

View file

@ -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(

View file

@ -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
) )
]) ])

View file

@ -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>

View file

@ -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
) )

View file

@ -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,
]
},
})

View file

@ -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} />
<EditorContent editor={editor} /> <ContentWrapper>
<TableOfContents 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 {

View file

@ -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

View file

@ -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)
} }
} }

View file

@ -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" />

View file

@ -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} <motion.div
initial="hidden" initial={{ opacity: 0, scale: 0.95 }}
animate="enter" animate={{
exit="exit" opacity: [0, 0.5, 1],
transition={{ type: 'linear' }} scale: 1,
className="" transition: {
> duration: 0.8,
<div className="max-w-7xl mx-auto px-4 py-20 transition-all"> scale: {
<div className="flex flex-col items-center justify-center h-40"> type: "spring",
{/* Animated dots */} stiffness: 50,
<div className="flex space-x-4"> damping: 15,
{[0, 1, 2, 3, 4].map((i) => ( delay: 0.2
<motion.div },
key={i} opacity: {
custom={i} duration: 0.6,
variants={dotVariants} times: [0, 0.6, 1]
initial="initial" }
animate="animate" }
className="w-4 h-4 rounded-full bg-gray-500 dark:bg-gray-400" }}
/> exit={{
))} opacity: 0,
</div> scale: 0.95,
transition: {
<motion.p duration: 0.4,
className="mt-6 text-sm text-gray-500 dark:text-gray-400" ease: "easeOut"
initial={{ opacity: 0 }} }
animate={{ opacity: [0, 1, 0] }} }}
transition={{ duration: 2, repeat: Infinity }} >
> <Loader2 className="w-10 h-10 text-gray-400 animate-spin" />
Loading... </motion.div>
</motion.p> </div>
</div>
</div>
</motion.main>
) )
} }

View 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>
);
}

View 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>
)
}

View file

@ -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

View file

@ -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,6 +15,86 @@ interface FixedActivitySecondaryBarProps {
activity: any 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 { export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryBarProps): React.ReactNode {
const router = useRouter(); const router = useRouter();
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
@ -22,12 +102,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
const mainActivityInfoRef = useRef<HTMLDivElement | null>(null); const mainActivityInfoRef = useRef<HTMLDivElement | null>(null);
const org = useOrg() as any; const org = useOrg() as any;
// Function to find the current activity's position in the course // Memoize activity position calculation
const findActivityPosition = () => { const { allActivities, currentIndex } = useMemo(() => {
let allActivities: any[] = []; let allActivities: any[] = [];
let currentIndex = -1; let currentIndex = -1;
// Flatten all activities from all chapters
props.course.chapters.forEach((chapter: any) => { props.course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => { chapter.activities.forEach((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', ''); const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
@ -37,7 +116,6 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
chapterName: chapter.name chapterName: chapter.name
}); });
// Check if this is the current activity
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) { if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
currentIndex = allActivities.length - 1; currentIndex = allActivities.length - 1;
} }
@ -45,15 +123,11 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
}); });
return { allActivities, currentIndex }; 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 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;
// Navigate to an activity
const navigateToActivity = (activity: any) => { const navigateToActivity = (activity: any) => {
if (!activity) return; if (!activity) return;
@ -61,32 +135,26 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`); router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
}; };
// Handle scroll and intersection observer
useEffect(() => { useEffect(() => {
const handleScroll = () => { const handleScroll = () => {
setIsScrolled(window.scrollY > 0); setIsScrolled(window.scrollY > 0);
}; };
// Set up intersection observer for the main activity info
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
// Show the fixed bar when the main info is not visible
setShouldShow(!entry.isIntersecting); setShouldShow(!entry.isIntersecting);
}, },
{ {
threshold: [0, 0.1, 1], 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 const mainActivityInfo = document.querySelector('.activity-info-section');
setTimeout(() => { if (mainActivityInfo) {
const mainActivityInfo = document.querySelector('.activity-info-section'); mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement;
if (mainActivityInfo) { observer.observe(mainActivityInfo);
mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement; }
observer.observe(mainActivityInfo);
}
}, 100);
window.addEventListener('scroll', handleScroll); window.addEventListener('scroll', handleScroll);
@ -98,86 +166,29 @@ export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryB
}; };
}, []); }, []);
if (!shouldShow) return null;
return ( return (
<> <div
{shouldShow && ( 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 ${
<div isScrolled ? 'nice-shadow' : ''
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">
<div className="container mx-auto px-4"> <CourseInfo course={props.course} org={org} />
<div className="flex items-center justify-between h-16 py-2">
{/* Left Section - Course Info and Navigation */} <div className="flex items-center flex-shrink-0">
<div className="flex items-center space-x-2 sm:space-x-4 min-w-0 flex-shrink"> <NavigationButtons
<img prevActivity={prevActivity}
className="w-[35px] sm:w-[45px] h-[20px] sm:h-[26px] rounded-md object-cover flex-shrink-0" nextActivity={nextActivity}
src={`${getCourseThumbnailMediaDirectory( currentIndex={currentIndex}
org?.org_uuid, allActivities={allActivities}
props.course.course_uuid, navigateToActivity={navigateToActivity}
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">
<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> </div>
</div> </div>
)} </div>
</> </div>
); );
} }

View file

@ -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 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
} }
// 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) { function ActivityIndicators(props: Props) {
const course = props.course const course = props.course
const orgslug = props.orgslug const orgslug = props.orgslug
const courseid = props.course_uuid.replace('course_', '') 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 done_activity_style = 'bg-teal-600 hover:bg-teal-700'
const black_activity_style = 'bg-zinc-300 hover:bg-zinc-400' const black_activity_style = 'bg-zinc-300 hover:bg-zinc-400'
@ -22,141 +111,132 @@ function ActivityIndicators(props: Props) {
const trail = props.course.trail 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( let run = props.course.trail?.runs.find(
(run: any) => run.course_id == props.course.id (run: any) => run.course_id == props.course.id
) )
if (run) { if (run) {
return run.steps.find((step: any) => step.activity_id == activity.id) 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_', '') let activity_uuid = activity.activity_uuid.replace('activity_', '')
if (props.current_activity && props.current_activity == activity_uuid) { if (props.current_activity && props.current_activity == activity_uuid) {
return true return true
} }
return false return false
} }, [props.current_activity]);
function getActivityClass(activity: any) { const getActivityClass = useMemo(() => (activity: any) => {
const isCurrent = isActivityCurrent(activity)
if (isActivityDone(activity)) { if (isActivityDone(activity)) {
return done_activity_style return `${done_activity_style}`
} }
if (isActivityCurrent(activity)) { if (isCurrent) {
return current_activity_style 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) => { const navigateToPrevious = () => {
switch (activityType) { if (currentActivityIndex > 0) {
case 'TYPE_VIDEO': const prevActivity = allActivities[currentActivityIndex - 1]
return <Video size={16} className="text-gray-400" /> const activityId = prevActivity.activity_uuid.replace('activity_', '')
case 'TYPE_DOCUMENT': router.push(getUriWithOrg(orgslug, '') + `/course/${courseid}/activity/${activityId}`)
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 getActivityTypeLabel = (activityType: string) => { const navigateToNext = () => {
switch (activityType) { if (currentActivityIndex < allActivities.length - 1) {
case 'TYPE_VIDEO': const nextActivity = allActivities[currentActivityIndex + 1]
return 'Video' const activityId = nextActivity.activity_uuid.replace('activity_', '')
case 'TYPE_DOCUMENT': router.push(getUriWithOrg(orgslug, '') + `/course/${courseid}/activity/${activityId}`)
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'
} }
} }
return ( return (
<div className="grid grid-flow-col justify-stretch space-x-6"> <div className="flex items-center gap-4">
{course.chapters.map((chapter: any) => { {enableNavigation && (
return ( <button
<React.Fragment key={chapter.id || `chapter-${chapter.name}`}> onClick={navigateToPrevious}
<div className="grid grid-flow-col justify-stretch space-x-2"> disabled={currentActivityIndex <= 0}
{chapter.activities.map((activity: any) => { className="p-1 rounded-full hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
const isDone = isActivityDone(activity) aria-label="Previous activity"
const isCurrent = isActivityCurrent(activity) >
return ( <ChevronLeft size={20} className="text-gray-600" />
<ToolTip </button>
sideOffset={8} )}
unstyled
content={ <div className="flex items-center w-full">
<div className="bg-white rounded-lg nice-shadow py-3 px-4 min-w-[200px] animate-in fade-in duration-200"> {allActivities.map((activity: any) => {
<div className="flex items-center gap-2"> const isDone = isActivityDone(activity)
{getActivityTypeIcon(activity.activity_type)} const isCurrent = isActivityCurrent(activity)
<span className="text-sm text-gray-700">{activity.name}</span> return (
{isDone && ( <ToolTip
<span className="ml-auto text-gray-400"> sideOffset={8}
<Check size={14} /> unstyled
</span> content={
)} <ActivityTooltipContent
</div> activity={activity}
<div className="flex items-center gap-2 mt-2"> isDone={isDone}
<span className={`text-xs px-2 py-0.5 rounded-full ${getActivityTypeBadgeColor(activity.activity_type)}`}> isCurrent={isCurrent}
{getActivityTypeLabel(activity.activity_type)} />
</span> }
<span className="text-xs text-gray-400"> key={activity.activity_uuid}
{isCurrent ? 'Current Activity' : isDone ? 'Completed' : 'Not Started'} >
</span> <Link
</div> prefetch={false}
</div> href={
} getUriWithOrg(orgslug, '') +
key={activity.activity_uuid} `/course/${courseid}/activity/${activity.activity_uuid.replace(
> 'activity_',
<Link ''
prefetch={false} )}`
href={ }
getUriWithOrg(orgslug, '') + className={`${isCurrent ? 'flex-[2]' : 'flex-1'} mx-1`}
`/course/${courseid}/activity/${activity.activity_uuid.replace( >
'activity_', <div
'' className={`h-[7px] ${getActivityClass(activity)} rounded-lg transition-all`}
)}` ></div>
} </Link>
> </ToolTip>
<div )
className={`h-[7px] w-auto ${getActivityClass( })}
activity </div>
)} rounded-lg`}
></div> {enableNavigation && (
</Link> <button
</ToolTip> 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"
</div> aria-label="Next activity"
</React.Fragment> >
) <ChevronRight size={20} className="text-gray-600" />
})} </button>
)}
</div> </div>
) )
} }
export default ActivityIndicators export default memo(ActivityIndicators)

View 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>
)
}

View file

@ -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
View file

@ -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: