Merge pull request #475 from learnhouse/feat/ux-improvements

UX improvements
This commit is contained in:
Badr B. 2025-04-25 21:38:43 +02:00 committed by GitHub
commit f299ecb278
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2283 additions and 854 deletions

View file

@ -0,0 +1,31 @@
"""Activity Details
Revision ID: a5afa69dd917
Revises: adb944cc8bec
Create Date: 2025-04-22 16:04:58.028488
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = 'a5afa69dd917'
down_revision: Union[str, None] = 'adb944cc8bec'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('activity', sa.Column('details', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('activity', 'details')
# ### end Alembic commands ###

View file

@ -32,6 +32,7 @@ class ActivityBase(SQLModel):
activity_type: ActivityTypeEnum
activity_sub_type: ActivitySubTypeEnum
content: dict = Field(default={}, sa_column=Column(JSON))
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
published: bool = False
@ -53,6 +54,7 @@ class ActivityCreate(ActivityBase):
chapter_id: int
activity_type: ActivityTypeEnum = ActivityTypeEnum.TYPE_CUSTOM
activity_sub_type: ActivitySubTypeEnum = ActivitySubTypeEnum.SUBTYPE_CUSTOM
details: dict = Field(default={}, sa_column=Column(JSON))
pass
@ -61,6 +63,7 @@ class ActivityUpdate(ActivityBase):
content: dict = Field(default={}, sa_column=Column(JSON))
activity_type: Optional[ActivityTypeEnum]
activity_sub_type: Optional[ActivitySubTypeEnum]
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
published_version: Optional[int]
version: Optional[int]
@ -72,4 +75,5 @@ class ActivityRead(ActivityBase):
activity_uuid: str
creation_date: str
update_date: str
details: Optional[dict] = Field(default=None, sa_column=Column(JSON))
pass

View file

@ -113,6 +113,7 @@ async def api_create_video_activity(
request: Request,
name: str = Form(),
chapter_id: str = Form(),
details: str = Form(default="{}"),
current_user: PublicUser = Depends(get_current_user),
video_file: UploadFile | None = None,
db_session=Depends(get_db_session),
@ -127,6 +128,7 @@ async def api_create_video_activity(
current_user,
db_session,
video_file,
details,
)

View file

@ -126,6 +126,7 @@ async def api_get_course_by_id(
async def api_get_course_meta(
request: Request,
course_uuid: str,
with_unpublished_activities: bool = False,
db_session: Session = Depends(get_db_session),
current_user: PublicUser = Depends(get_current_user),
) -> FullCourseReadWithTrail:
@ -133,7 +134,7 @@ async def api_get_course_meta(
Get single Course Metadata (chapters, activities) by course_uuid
"""
return await get_course_meta(
request, course_uuid, current_user=current_user, db_session=db_session
request, course_uuid, with_unpublished_activities, current_user=current_user, db_session=db_session
)

View file

@ -10,6 +10,7 @@ from src.services.trail.trail import (
get_user_trails,
get_user_trail_with_orgid,
remove_course_from_trail,
remove_activity_from_trail,
)
@ -95,3 +96,16 @@ async def api_add_activity_to_trail(
return await add_activity_to_trail(
request, user, activity_uuid, db_session
)
@router.delete("/remove_activity/{activity_uuid}")
async def api_remove_activity_from_trail(
request: Request,
activity_uuid: str,
user=Depends(get_current_user),
db_session=Depends(get_db_session),
) -> TrailRead:
"""
Remove Activity from trail
"""
return await remove_activity_from_trail(request, user, activity_uuid, db_session)

View file

@ -260,15 +260,21 @@ async def get_activities(
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> list[ActivityRead]:
statement = select(ChapterActivity).where(
ChapterActivity.chapter_id == coursechapter_id
# Get activities that are published and belong to the chapter
statement = (
select(Activity)
.join(ChapterActivity)
.where(
ChapterActivity.chapter_id == coursechapter_id,
Activity.published == True
)
)
activities = db_session.exec(statement).all()
if not activities:
raise HTTPException(
status_code=404,
detail="No activities found",
detail="No published activities found",
)
# RBAC check

View file

@ -1,4 +1,5 @@
from typing import Literal
import json
from src.db.courses.courses import Course
from src.db.organizations import Organization
@ -31,6 +32,7 @@ async def create_video_activity(
current_user: PublicUser,
db_session: Session,
video_file: UploadFile | None = None,
details: str = "{}",
):
# RBAC check
await rbac_check(request, "activity_x", current_user, "create", db_session)
@ -39,6 +41,9 @@ async def create_video_activity(
statement = select(Chapter).where(Chapter.id == chapter_id)
chapter = db_session.exec(statement).first()
# convert details to dict
details = json.loads(details)
if not chapter:
raise HTTPException(
status_code=404,
@ -99,6 +104,7 @@ async def create_video_activity(
"filename": "video." + video_format,
"activity_uuid": activity_uuid,
},
details=details,
version=1,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
@ -144,6 +150,7 @@ class ExternalVideo(BaseModel):
uri: str
type: Literal["youtube", "vimeo"]
chapter_id: str
details: str = "{}"
class ExternalVideoInDB(BaseModel):
@ -181,6 +188,9 @@ async def create_external_video_activity(
# generate activity_uuid
activity_uuid = str(f"activity_{uuid4()}")
# convert details to dict
details = json.loads(data.details)
activity_object = Activity(
name=data.name,
activity_type=ActivityTypeEnum.TYPE_VIDEO,
@ -194,6 +204,7 @@ async def create_external_video_activity(
"type": data.type,
"activity_uuid": activity_uuid,
},
details=details,
version=1,
creation_date=str(datetime.now()),
update_date=str(datetime.now()),

View file

@ -214,6 +214,7 @@ async def get_course_chapters(
course_id: int,
db_session: Session,
current_user: PublicUser | AnonymousUser,
with_unpublished_activities: bool,
page: int = 1,
limit: int = 10,
) -> List[ChapterRead]:
@ -249,7 +250,7 @@ async def get_course_chapters(
for chapter_activity in chapter_activities:
statement = (
select(Activity)
.where(Activity.id == chapter_activity.activity_id)
.where(Activity.id == chapter_activity.activity_id, with_unpublished_activities or Activity.published == True)
.distinct(Activity.id)
)
activity = db_session.exec(statement).first()

View file

@ -126,6 +126,7 @@ async def get_course_by_id(
async def get_course_meta(
request: Request,
course_uuid: str,
with_unpublished_activities: bool,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> FullCourseReadWithTrail:
@ -165,7 +166,7 @@ async def get_course_meta(
# Ensure course.id is not None
if course.id is None:
return []
return await get_course_chapters(request, course.id, db_session, current_user)
return await get_course_chapters(request, course.id, db_session, current_user, with_unpublished_activities)
# Task 3: Get user trail (only for authenticated users)
async def get_trail():

View file

@ -282,6 +282,79 @@ async def add_activity_to_trail(
return trail_read
async def remove_activity_from_trail(
request: Request,
user: PublicUser,
activity_uuid: str,
db_session: Session,
) -> TrailRead:
# Look for the activity
statement = select(Activity).where(Activity.activity_uuid == activity_uuid)
activity = db_session.exec(statement).first()
if not activity:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Activity not found"
)
statement = select(Course).where(Course.id == activity.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Course not found"
)
statement = select(Trail).where(
Trail.org_id == course.org_id, Trail.user_id == user.id
)
trail = db_session.exec(statement).first()
if not trail:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Trail not found"
)
# Delete the trail step for this activity
statement = select(TrailStep).where(
TrailStep.activity_id == activity.id,
TrailStep.user_id == user.id,
TrailStep.trail_id == trail.id
)
trail_step = db_session.exec(statement).first()
if trail_step:
db_session.delete(trail_step)
db_session.commit()
# Get updated trail data
statement = select(TrailRun).where(TrailRun.trail_id == trail.id, TrailRun.user_id == user.id)
trail_runs = db_session.exec(statement).all()
trail_runs = [
TrailRunRead(**trail_run.__dict__, course={}, steps=[], course_total_steps=0)
for trail_run in trail_runs
]
for trail_run in trail_runs:
statement = select(TrailStep).where(TrailStep.trailrun_id == trail_run.id, TrailStep.user_id == user.id)
trail_steps = db_session.exec(statement).all()
trail_steps = [TrailStep(**trail_step.__dict__) for trail_step in trail_steps]
trail_run.steps = trail_steps
for trail_step in trail_steps:
statement = select(Course).where(Course.id == trail_step.course_id)
course = db_session.exec(statement).first()
trail_step.data = dict(course=course)
trail_read = TrailRead(
**trail.model_dump(),
runs=trail_runs,
)
return trail_read
async def add_course_to_trail(
request: Request,

View file

@ -4,7 +4,7 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import Canva from '@components/Objects/Activities/DynamicCanva/DynamicCanva'
import VideoActivity from '@components/Objects/Activities/Video/Video'
import { BookOpenCheck, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, FileText, Folder, List, Menu, MoreVertical, UserRoundPen, Video, Layers, ListFilter, ListTree, X, Edit2 } from 'lucide-react'
import { markActivityAsComplete } from '@services/courses/activity'
import { markActivityAsComplete, unmarkActivityAsComplete } from '@services/courses/activity'
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
@ -28,6 +28,11 @@ import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationMo
import { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
import { useContributorStatus } from '../../../../../../../../hooks/useContributorStatus'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import ActivityNavigation from '@components/Pages/Activity/ActivityNavigation'
import ActivityChapterDropdown from '@components/Pages/Activity/ActivityChapterDropdown'
import FixedActivitySecondaryBar from '@components/Pages/Activity/FixedActivitySecondaryBar'
import CourseEndView from '@components/Pages/Activity/CourseEndView'
interface ActivityClientProps {
activityid: string
@ -91,159 +96,175 @@ function ActivityClient(props: ActivityClientProps) {
<CourseProvider courseuuid={course?.course_uuid}>
<AIChatBotProvider>
<GeneralWrapperStyled>
<div className="space-y-4 pt-4">
<div className="flex justify-between items-center">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
</div>
<ActivityIndicators
course_uuid={courseuuid}
current_activity={activityid}
{activityid === 'end' ? (
<CourseEndView
courseName={course.name}
orgslug={orgslug}
course={course}
courseUuid={course.course_uuid}
thumbnailImage={course.thumbnail_image}
/>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<ActivityChapterDropdown
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">
Chapter : {getChapterNameByActivityId(course, activity.id)}
</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{activity.name}
</h1>
</div>
</div>
<div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
<>
<AIActivityAsk activity={activity} />
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute to Activity</span>
</Link>
)}
<MoreVertical size={17} className="text-gray-300" />
<MarkStatus
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</>
}
{activity.activity_type == 'TYPE_ASSIGNMENT' &&
<>
<MoreVertical size={17} className="text-gray-300 " />
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentTools
assignment={assignment}
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
) : (
<div className="space-y-4 pt-0">
<div className="pt-2">
<div className="space-y-4 pb-4 activity-info-section">
<div className="flex justify-between items-center">
<div className="flex space-x-6">
<div className="flex">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}`}
>
<img
className="w-[100px] h-[57px] rounded-md drop-shadow-md"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.course_uuid,
course.thumbnail_image
)}`}
alt=""
/>
</AssignmentSubmissionProvider>
</>
}
</AuthenticatedClientElement>
)}
</div>
</div>
{activity && activity.published == false && (
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
<div className="text-white">
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)}
</Link>
</div>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">Course </p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{course.name}
</h1>
</div>
</div>
</div>
{activity && activity.published == true && (
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
<ActivityIndicators
course_uuid={courseuuid}
current_activity={activityid}
orgslug={orgslug}
course={course}
/>
<div className="flex justify-between items-center">
<div className="flex items-center space-x-3">
<ActivityChapterDropdown
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
<div className="flex flex-col -space-y-1">
<p className="font-bold text-gray-700 text-md">
Chapter : {getChapterNameByActivityId(course, activity.id)}
</p>
<h1 className="font-bold text-gray-950 text-2xl first-letter:uppercase">
{activity.name}
</h1>
</div>
</div>
<div className="flex space-x-2 items-center">
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && (
<>
<AIActivityAsk activity={activity} />
{contributorStatus === 'ACTIVE' && activity.activity_type == 'TYPE_DYNAMIC' && (
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseuuid}/activity/${activityid}/edit`}
className="bg-emerald-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
<Edit2 size={17} />
<span className="text-xs font-bold">Contribute to Activity</span>
</Link>
)}
<MoreVertical size={17} className="text-gray-300" />
<MarkStatus
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</>
)}
</div>
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<>
<MoreVertical size={17} className="text-gray-300 " />
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentTools
assignment={assignment}
activity={activity}
activityid={activityid}
course={course}
orgslug={orgslug}
/>
</AssignmentSubmissionProvider>
</>
)}
</AuthenticatedClientElement>
)}
</div>
</div>
</div>
{activity && activity.published == false && (
<div className="p-7 drop-shadow-xs rounded-lg bg-gray-800">
<div className="text-white">
<h1 className="font-bold text-2xl">
This activity is not published yet
</h1>
</div>
</div>
)}
</>
)}
{/* Activity Navigation */}
{activity && activity.published == true && activity.content.paid_access != false && (
<ActivityNavigation
course={course}
currentActivityId={activity.activity_uuid ? activity.activity_uuid.replace('activity_', '') : activityid.replace('activity_', '')}
orgslug={orgslug}
/>
)}
{activity && activity.published == true && (
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<div className={`p-7 drop-shadow-xs rounded-lg ${bgColor}`}>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
)}
</div>
)}
</div>
</div>
)}
</>
)}
{<div style={{ height: '100px' }}></div>}
</div>
{/* Fixed Activity Secondary Bar */}
{activity && activity.published == true && activity.content.paid_access != false && (
<FixedActivitySecondaryBar
course={course}
currentActivityId={activityid}
orgslug={orgslug}
activity={activity}
/>
)}
<div style={{ height: '100px' }}></div>
</div>
</div>
)}
</GeneralWrapperStyled>
</AIChatBotProvider>
</CourseProvider>
@ -262,10 +283,73 @@ export function MarkStatus(props: {
const isMobile = useMediaQuery('(max-width: 768px)')
const [isLoading, setIsLoading] = React.useState(false);
const areAllActivitiesCompleted = () => {
const run = props.course.trail.runs.find(
(run: any) => run.course_id == props.course.id
);
if (!run) return false;
let totalActivities = 0;
let completedActivities = 0;
// Count all activities and completed activities
props.course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => {
totalActivities++;
const isCompleted = run.steps.find(
(step: any) => step.activity_id === activity.id && step.complete === true
);
if (isCompleted) {
completedActivities++;
}
});
});
console.log('Total activities:', totalActivities);
console.log('Completed activities:', completedActivities);
console.log('All completed?', completedActivities >= totalActivities - 1);
// We check for totalActivities - 1 because the current activity completion
// hasn't been counted yet (it's in progress)
return completedActivities >= totalActivities - 1;
};
async function markActivityAsCompleteFront() {
try {
// Check if this will be the last activity to complete
const willCompleteAll = areAllActivitiesCompleted();
console.log('Will complete all?', willCompleteAll);
setIsLoading(true);
const trail = await markActivityAsComplete(
await markActivityAsComplete(
props.orgslug,
props.course.course_uuid,
props.activity.activity_uuid,
session.data?.tokens?.access_token
);
// Mutate the course data
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
if (willCompleteAll) {
console.log('Redirecting to end page...');
const cleanCourseUuid = props.course.course_uuid.replace('course_', '');
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/end`);
} else {
router.refresh();
}
} catch (error) {
console.error('Error marking activity as complete:', error);
toast.error('Failed to mark activity as complete');
} finally {
setIsLoading(false);
}
}
async function unmarkActivityAsCompleteFront() {
try {
setIsLoading(true);
const trail = await unmarkActivityAsComplete(
props.orgslug,
props.course.course_uuid,
props.activity.activity_uuid,
@ -276,7 +360,7 @@ export function MarkStatus(props: {
await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`);
router.refresh();
} catch (error) {
toast.error('Failed to mark activity as complete');
toast.error('Failed to unmark activity as complete');
} finally {
setIsLoading(false);
}
@ -296,36 +380,121 @@ export function MarkStatus(props: {
return (
<>
{isActivityCompleted() ? (
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out">
<i>
<Check size={17}></Check>
</i>{' '}
<i className="not-italic text-xs font-bold">Complete</i>
</div>
) : (
<div
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-gray-800 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
onClick={!isLoading ? markActivityAsCompleteFront : undefined}
>
{isLoading ? (
<div className="animate-spin">
<svg className="w-4 h-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : (
<div className="flex items-center space-x-2">
<div className="bg-teal-600 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white">
<i>
<Check size={17}></Check>
</i>
)}{' '}
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</i>}
</i>{' '}
<i className="not-italic text-xs font-bold">Complete</i>
</div>
<ToolTip
content="Unmark as complete"
side="top"
>
<ConfirmationModal
confirmationButtonText="Unmark Activity"
confirmationMessage="Are you sure you want to unmark this activity as complete? This will affect your course progress."
dialogTitle="Unmark activity as complete"
dialogTrigger={
<div
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-red-400 rounded-full p-2 drop-shadow-md flex items-center text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
>
{isLoading ? (
<div className="animate-spin">
<svg className="w-4 h-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : (
<X size={17} />
)}
</div>
}
functionToExecute={unmarkActivityAsCompleteFront}
status="warning"
/>
</ToolTip>
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
</div>
) : (
<div className="flex items-center space-x-2">
<div
className={`${isLoading ? 'opacity-75 cursor-not-allowed' : ''} bg-gray-800 rounded-full px-5 drop-shadow-md flex items-center space-x-2 p-2.5 text-white hover:cursor-pointer transition delay-150 duration-300 ease-in-out`}
onClick={!isLoading ? markActivityAsCompleteFront : undefined}
>
{isLoading ? (
<div className="animate-spin">
<svg className="w-4 h-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
) : (
<i>
<Check size={17}></Check>
</i>
)}{' '}
{!isMobile && <i className="not-italic text-xs font-bold">{isLoading ? 'Marking...' : 'Mark as complete'}</i>}
</div>
<NextActivityButton course={props.course} currentActivityId={props.activity.id} orgslug={props.orgslug} />
</div>
)}
</>
)
}
function NextActivityButton({ course, currentActivityId, orgslug }: { course: any, currentActivityId: string, orgslug: string }) {
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const findNextActivity = () => {
let allActivities: any[] = [];
let currentIndex = -1;
// Flatten all activities from all chapters
course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
allActivities.push({
...activity,
cleanUuid: cleanActivityUuid,
chapterName: chapter.name
});
// Check if this is the current activity
if (activity.id === currentActivityId) {
currentIndex = allActivities.length - 1;
}
});
});
// Get next activity
return currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
};
const nextActivity = findNextActivity();
if (!nextActivity) return null;
const navigateToActivity = () => {
const cleanCourseUuid = course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(orgslug, '') + `/course/${cleanCourseUuid}/activity/${nextActivity.cleanUuid}`);
};
return (
<ToolTip content={`Next: ${nextActivity.name}`} side="top">
<div
onClick={navigateToActivity}
className="bg-gray-300 rounded-full px-5 nice-shadow flex items-center space-x-2 p-2.5 text-gray-600 hover:cursor-pointer transition delay-150 duration-300 ease-in-out"
>
{!isMobile && <span className="text-xs font-bold">Next</span>}
<ChevronRight size={17} />
</div>
</ToolTip>
);
}
function AssignmentTools(props: {
activity: any
activityid: string
@ -442,335 +611,4 @@ function AssignmentTools(props: {
return null
}
function ActivityChapterDropdown(props: {
course: any
currentActivityId: string
orgslug: string
}): React.ReactNode {
const [isOpen, setIsOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery('(max-width: 768px)');
// Close dropdown when clicking outside
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
// Function to get the appropriate icon for activity type
const getActivityTypeIcon = (activityType: string) => {
switch (activityType) {
case 'TYPE_VIDEO':
return <Video size={16} />;
case 'TYPE_DOCUMENT':
return <FileText size={16} />;
case 'TYPE_DYNAMIC':
return <Layers size={16} />;
case 'TYPE_ASSIGNMENT':
return <BookOpenCheck size={16} />;
default:
return <FileText size={16} />;
}
};
// Function to get the appropriate badge color for activity type
const getActivityTypeBadgeColor = (activityType: string) => {
return 'bg-gray-100 text-gray-600';
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center justify-center bg-white nice-shadow p-2.5 rounded-full cursor-pointer"
aria-label="View all activities"
title="View all activities"
>
<ListTree size={18} className="text-gray-700" />
</button>
{isOpen && (
<div className={`absolute z-50 mt-2 ${isMobile ? 'left-0 w-[90vw] sm:w-80' : 'left-0 w-80'} max-h-[70vh] cursor-pointer overflow-y-auto bg-white rounded-lg shadow-xl border border-gray-200 py-2 animate-in fade-in duration-200`}>
<div className="px-4 py-2 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-bold text-gray-800">Course Content</h3>
<button
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 p-1 rounded-full hover:bg-gray-100 cursor-pointer"
>
<X size={18} />
</button>
</div>
<div className="py-1">
{props.course.chapters.map((chapter: any) => (
<div key={chapter.id} className="mb-2">
<div className="px-4 py-2 font-medium text-gray-600 bg-gray-50 border-y border-gray-100 flex items-center">
<div className="flex items-center space-x-2">
<Folder size={16} className="text-gray-400" />
<span>{chapter.name}</span>
</div>
</div>
<div className="py-1">
{chapter.activities.map((activity: any) => {
// Remove any prefixes from UUIDs
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
return (
<Link
key={activity.id}
href={getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${cleanActivityUuid}`}
prefetch={false}
onClick={() => setIsOpen(false)}
>
<div
className={`px-4 py-2.5 hover:bg-gray-100 transition-colors flex items-center ${
cleanActivityUuid === props.currentActivityId.replace('activity_', '') ? 'bg-gray-50 border-l-2 border-gray-300 pl-3 font-medium' : ''
}`}
>
<div className="flex-1 flex items-center gap-2">
<span className="text-gray-400">
{getActivityTypeIcon(activity.activity_type)}
</span>
<div className="text-sm">
{activity.name}
</div>
</div>
{props.course.trail?.runs?.find(
(run: any) => run.course_id === props.course.id
)?.steps?.find(
(step: any) => (step.activity_id === activity.id || step.activity_id === activity.activity_uuid) && step.complete === true
) && (
<span className="ml-2 text-gray-400 shrink-0">
<Check size={14} />
</span>
)}
</div>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
function ActivityNavigation(props: {
course: any
currentActivityId: string
orgslug: string
}): React.ReactNode {
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const [isBottomNavVisible, setIsBottomNavVisible] = React.useState(true);
const bottomNavRef = React.useRef<HTMLDivElement>(null);
const [navWidth, setNavWidth] = React.useState<number | null>(null);
// Function to find the current activity's position in the course
const findActivityPosition = () => {
let allActivities: any[] = [];
let currentIndex = -1;
// Flatten all activities from all chapters
props.course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
allActivities.push({
...activity,
cleanUuid: cleanActivityUuid,
chapterName: chapter.name
});
// Check if this is the current activity
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
currentIndex = allActivities.length - 1;
}
});
});
return { allActivities, currentIndex };
};
const { allActivities, currentIndex } = findActivityPosition();
// Get previous and next activities
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
// Navigate to an activity
const navigateToActivity = (activity: any) => {
if (!activity) return;
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
};
// Set up intersection observer to detect when bottom nav is out of viewport
// and measure the width of the bottom navigation
React.useEffect(() => {
if (!bottomNavRef.current) return;
// Update width when component mounts and on window resize
const updateWidth = () => {
if (bottomNavRef.current) {
setNavWidth(bottomNavRef.current.offsetWidth);
}
};
// Initial width measurement
updateWidth();
// Set up resize listener
window.addEventListener('resize', updateWidth);
const observer = new IntersectionObserver(
([entry]) => {
setIsBottomNavVisible(entry.isIntersecting);
},
{ threshold: 0.1 }
);
observer.observe(bottomNavRef.current);
return () => {
window.removeEventListener('resize', updateWidth);
if (bottomNavRef.current) {
observer.unobserve(bottomNavRef.current);
}
};
}, []);
// Navigation buttons component - reused for both top and bottom
const NavigationButtons = ({ isFloating = false }) => (
<div className={`${isFloating ? 'flex justify-between' : 'grid grid-cols-3'} items-center w-full`}>
{isFloating ? (
// Floating navigation - original flex layout
<>
<button
onClick={() => navigateToActivity(prevActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
prevActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!prevActivity}
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
>
<ChevronLeft size={20} className="text-gray-800 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-xs text-gray-500">Previous</span>
<span className="text-sm capitalize font-semibold text-left">
{prevActivity ? prevActivity.name : 'No previous activity'}
</span>
</div>
</button>
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
nextActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>
<div className="flex flex-col items-end">
<span className="text-xs text-gray-500">Next</span>
<span className="text-sm capitalize font-semibold text-right">
{nextActivity ? nextActivity.name : 'No next activity'}
</span>
</div>
<ChevronRight size={20} className="text-gray-800 shrink-0" />
</button>
</>
) : (
// Regular navigation - grid layout with centered counter
<>
<div className="justify-self-start">
<button
onClick={() => navigateToActivity(prevActivity)}
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
prevActivity
? 'bg-white nice-shadow text-gray-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!prevActivity}
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
>
<ChevronLeft size={16} className="shrink-0" />
<div className="flex flex-col items-start">
<span className="text-xs text-gray-500">Previous</span>
<span className="text-sm capitalize font-semibold text-left">
{prevActivity ? prevActivity.name : 'No previous activity'}
</span>
</div>
</button>
</div>
<div className="text-sm text-gray-500 justify-self-center">
{currentIndex + 1} of {allActivities.length}
</div>
<div className="justify-self-end">
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
nextActivity
? 'bg-white nice-shadow text-gray-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>
<div className="flex flex-col items-end">
<span className="text-xs text-gray-500">Next</span>
<span className="text-sm capitalize font-semibold text-right">
{nextActivity ? nextActivity.name : 'No next activity'}
</span>
</div>
<ChevronRight size={16} className="shrink-0" />
</button>
</div>
</>
)}
</div>
);
return (
<>
{/* Bottom navigation (in-place) */}
<div ref={bottomNavRef} className="mt-6 mb-2 w-full">
<NavigationButtons isFloating={false} />
</div>
{/* Floating bottom navigation - shown when bottom nav is not visible */}
{!isBottomNavVisible && (
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 z-50 w-[85%] sm:w-auto sm:min-w-[350px] max-w-lg transition-all duration-300 ease-in-out">
<div
className="bg-white/90 backdrop-blur-xl rounded-full py-1.5 px-2.5 shadow-xs animate-in fade-in slide-in-from-bottom duration-300"
>
<NavigationButtons isFloating={true} />
</div>
</div>
)}
</>
);
}
export default ActivityClient

View file

@ -25,7 +25,7 @@ function CourseOverviewPage(props: { params: Promise<CourseOverviewParams> }) {
return (
<div className="h-screen w-full bg-[#f8f8f8] grid grid-rows-[auto_1fr]">
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)}>
<CourseProvider courseuuid={getEntireCourseUUID(params.courseuuid)} withUnpublishedActivities={true}>
<div className="pl-10 pr-10 text-sm tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<CourseOverviewTop params={params} />
<div className="flex space-x-3 font-black text-sm">

View file

@ -8,11 +8,11 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
export const CourseContext = createContext(null)
export const CourseDispatchContext = createContext(null)
export function CourseProvider({ children, courseuuid }: any) {
export function CourseProvider({ children, courseuuid, withUnpublishedActivities = false }: any) {
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const { data: courseStructureData, error } = useSWR(`${getAPIUrl()}courses/${courseuuid}/meta`,
const { data: courseStructureData, error } = useSWR(`${getAPIUrl()}courses/${courseuuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`,
url => swrFetcher(url, access_token)
);
@ -22,7 +22,8 @@ export function CourseProvider({ children, courseuuid }: any) {
},
courseOrder: {},
isSaved: true,
isLoading: true
isLoading: true,
withUnpublishedActivities: withUnpublishedActivities
};
const [state, dispatch] = useReducer(courseReducer, initialState) as any;

View file

@ -6,37 +6,43 @@ import {
useCourse,
useCourseDispatch,
} from '@components/Contexts/CourseContext'
import { Check, SaveAllIcon, Timer } from 'lucide-react'
import { Check, SaveAllIcon, Timer, Loader2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { mutate } from 'swr'
import { updateCourse } from '@services/courses/courses'
import { useLHSession } from '@components/Contexts/LHSessionContext'
function SaveState(props: { orgslug: string }) {
const [isLoading, setIsLoading] = useState(false)
const course = useCourse() as any
const session = useLHSession() as any;
const router = useRouter()
const saved = course ? course.isSaved : true
const dispatchCourse = useCourseDispatch() as any
const course_structure = course.courseStructure
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const saveCourseState = async () => {
// Course order
if (saved) return
await changeOrderBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
// Course metadata
await changeMetadataBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
await revalidateTags(['courses'], props.orgslug)
dispatchCourse({ type: 'setIsSaved' })
if (saved || isLoading) return
setIsLoading(true)
try {
// Course order
await changeOrderBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
// Course metadata
await changeMetadataBackend()
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
dispatchCourse({ type: 'setIsSaved' })
} finally {
setIsLoading(false)
}
}
//
// Course Order
const changeOrderBackend = async () => {
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await updateCourseOrderStructure(
course.courseStructure.course_uuid,
course.courseOrder,
@ -49,7 +55,7 @@ function SaveState(props: { orgslug: string }) {
// Course metadata
const changeMetadataBackend = async () => {
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await updateCourse(
course.courseStructure.course_uuid,
course.courseStructure,
@ -117,12 +123,25 @@ function SaveState(props: { orgslug: string }) {
`px-4 py-2 rounded-lg drop-shadow-md cursor-pointer flex space-x-2 items-center font-bold antialiased transition-all ease-linear ` +
(saved
? 'bg-gray-600 text-white'
: 'bg-black text-white border hover:bg-gray-900 ')
: 'bg-black text-white border hover:bg-gray-900 ') +
(isLoading ? 'opacity-50 cursor-not-allowed' : '')
}
onClick={saveCourseState}
>
{saved ? <Check size={20} /> : <SaveAllIcon size={20} />}
{saved ? <div className="">Saved</div> : <div className="">Save</div>}
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : saved ? (
<Check size={20} />
) : (
<SaveAllIcon size={20} />
)}
{isLoading ? (
<div className="">Saving...</div>
) : saved ? (
<div className="">Saved</div>
) : (
<div className="">Save</div>
)}
</div>
</div>
)

View file

@ -5,111 +5,180 @@ import { updateCourseThumbnail } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { ArrowBigUpDash, UploadCloud, Image as ImageIcon } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { mutate } from 'swr'
import UnsplashImagePicker from './UnsplashImagePicker'
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const VALID_MIME_TYPES = ['image/jpeg', 'image/jpg', 'image/png'] as const;
type ValidMimeType = typeof VALID_MIME_TYPES[number];
function ThumbnailUpdate() {
const course = useCourse() as any
const session = useLHSession() as any;
const org = useOrg() as any
const [localThumbnail, setLocalThumbnail] = React.useState(null) as any
const [isLoading, setIsLoading] = React.useState(false) as any
const [error, setError] = React.useState('') as any
const [localThumbnail, setLocalThumbnail] = useState<{ file: File; url: string } | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string>('')
const [showUnsplashPicker, setShowUnsplashPicker] = useState(false)
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const handleFileChange = async (event: any) => {
const file = event.target.files[0]
setLocalThumbnail(file)
await updateThumbnail(file)
// Cleanup blob URLs when component unmounts or when thumbnail changes
useEffect(() => {
return () => {
if (localThumbnail?.url) {
URL.revokeObjectURL(localThumbnail.url);
}
};
}, [localThumbnail]);
const validateFile = (file: File): boolean => {
if (!VALID_MIME_TYPES.includes(file.type as ValidMimeType)) {
setError('Please upload only PNG or JPG/JPEG images');
return false;
}
if (file.size > MAX_FILE_SIZE) {
setError('File size should be less than 5MB');
return false;
}
return true;
}
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!validateFile(file)) {
event.target.value = '';
return;
}
const blobUrl = URL.createObjectURL(file);
setLocalThumbnail({ file, url: blobUrl });
await updateThumbnail(file);
}
const handleUnsplashSelect = async (imageUrl: string) => {
setIsLoading(true)
const response = await fetch(imageUrl)
const blob = await response.blob()
const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' })
setLocalThumbnail(file)
await updateThumbnail(file)
try {
setIsLoading(true);
const response = await fetch(imageUrl);
const blob = await response.blob();
if (!VALID_MIME_TYPES.includes(blob.type as ValidMimeType)) {
throw new Error('Invalid image format from Unsplash');
}
const file = new File([blob], `unsplash_${Date.now()}.jpg`, { type: blob.type });
if (!validateFile(file)) {
return;
}
const blobUrl = URL.createObjectURL(file);
setLocalThumbnail({ file, url: blobUrl });
await updateThumbnail(file);
} catch (err) {
setError('Failed to process Unsplash image');
setIsLoading(false);
}
}
const updateThumbnail = async (file: File) => {
setIsLoading(true)
const res = await updateCourseThumbnail(
course.courseStructure.course_uuid,
file,
session.data?.tokens?.access_token
)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
// wait for 1 second to show loading animation
await new Promise((r) => setTimeout(r, 1500))
if (res.success === false) {
setError(res.HTTPmessage)
} else {
setIsLoading(false)
setError('')
setIsLoading(true);
try {
const res = await updateCourseThumbnail(
course.courseStructure.course_uuid,
file,
session.data?.tokens?.access_token
);
await mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`);
await new Promise((r) => setTimeout(r, 1500));
if (res.success === false) {
setError(res.HTTPmessage);
} else {
setError('');
}
} catch (err) {
setError('Failed to update thumbnail');
} finally {
setIsLoading(false);
}
}
return (
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow-sm">
<div className="flex flex-col justify-center items-center h-full">
<div className="flex flex-col justify-center items-center">
<div className="flex flex-col justify-center items-center">
{error && (
<div className="flex justify-center bg-red-200 rounded-md text-red-950 space-x-2 items-center p-2 transition-all shadow-xs">
<div className="text-sm font-semibold">{error}</div>
</div>
)}
{localThumbnail ? (
<img
src={URL.createObjectURL(localThumbnail)}
className={`${isLoading ? 'animate-pulse' : ''} shadow-sm w-[200px] h-[100px] rounded-md`}
/>
) : (
<img
src={`${course.courseStructure.thumbnail_image ? getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.courseStructure.course_uuid,
course.courseStructure.thumbnail_image
) : '/empty_thumbnail.png'}`}
className="shadow-sm w-[200px] h-[100px] rounded-md bg-gray-200"
/>
)}
<div className="w-auto rounded-xl border border-gray-200 h-[250px] light-shadow bg-gray-50 transition-all duration-200">
<div className="flex flex-col justify-center items-center h-full p-6 space-y-4">
{error && (
<div className="absolute top-4 flex justify-center bg-red-50 rounded-lg text-red-800 space-x-2 items-center p-3 transition-all">
<div className="text-sm font-medium">{error}</div>
</div>
{isLoading ? (
<div className="flex justify-center items-center">
<div className="font-bold animate-pulse antialiased items-center bg-green-200 text-gray text-sm rounded-md px-4 py-2 mt-4 flex">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
</div>
)}
<div className="flex flex-col items-center space-y-4">
{localThumbnail ? (
<img
src={localThumbnail.url}
className={`${
isLoading ? 'animate-pulse' : ''
} shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200`}
alt="Course thumbnail"
/>
) : (
<div className="flex justify-center items-center space-x-2">
<img
src={`${course.courseStructure.thumbnail_image ? getCourseThumbnailMediaDirectory(
org?.org_uuid,
course.courseStructure.course_uuid,
course.courseStructure.thumbnail_image
) : '/empty_thumbnail.png'}`}
className="shadow-sm w-[280px] h-[140px] object-cover rounded-lg border border-gray-200 bg-gray-50"
alt="Course thumbnail"
/>
)}
{!isLoading && (
<div className="flex space-x-2">
<input
type="file"
id="fileInput"
style={{ display: 'none' }}
className="hidden"
accept=".jpg,.jpeg,.png"
onChange={handleFileChange}
/>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
onClick={() => document.getElementById('fileInput')?.click()}
>
<UploadCloud size={16} className="mr-2" />
<span>Upload Image</span>
Upload
</button>
<button
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
className="bg-gray-50 text-gray-800 px-4 py-2 rounded-md text-sm font-medium flex items-center hover:bg-gray-100 transition-colors duration-200 border border-gray-200"
onClick={() => setShowUnsplashPicker(true)}
>
<ImageIcon size={16} className="mr-2" />
<span>Choose from Gallery</span>
Gallery
</button>
</div>
)}
</div>
{isLoading && (
<div className="flex justify-center items-center">
<div className="font-medium text-sm text-green-800 bg-green-50 rounded-full px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2 animate-bounce" />
Uploading...
</div>
</div>
)}
<p className="text-xs text-gray-500">Supported formats: PNG, JPG/JPEG</p>
</div>
{showUnsplashPicker && (
<UnsplashImagePicker
onSelect={handleUnsplashSelect}

View file

@ -27,6 +27,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
const course = useCourse() as any
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const openNewActivityModal = async (chapterId: any) => {
setNewActivityModal(true)
@ -44,7 +45,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
)
const toast_loading = toast.loading('Creating activity...')
await createActivity(activity, props.chapterId, org.org_id, access_token)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
toast.dismiss(toast_loading)
toast.success('Activity created successfully')
setNewActivityModal(false)
@ -61,7 +62,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
) => {
toast.loading('Uploading file and creating activity...')
await createFileActivity(file, type, activity, chapterId, access_token)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
setNewActivityModal(false)
toast.dismiss()
toast.success('File uploaded successfully')
@ -82,7 +83,7 @@ function NewActivityButton(props: NewActivityButtonProps) {
activity,
props.chapterId, access_token
)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
setNewActivityModal(false)
toast.dismiss(toast_loading)
toast.success('Activity created successfully')

View file

@ -56,6 +56,8 @@ function ActivityElement(props: ActivitiyElementProps) {
const [isUpdatingName, setIsUpdatingName] = React.useState<boolean>(false)
const activityUUID = props.activity.activity_uuid
const isMobile = useMediaQuery('(max-width: 767px)')
const course = useCourse() as any;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
async function deleteActivityUI() {
const toast_loading = toast.loading('Deleting activity...')
@ -65,7 +67,7 @@ function ActivityElement(props: ActivitiyElementProps) {
}
await deleteActivity(props.activity.activity_uuid, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
toast.dismiss(toast_loading)
toast.success('Activity deleted successfully')
@ -82,7 +84,7 @@ function ActivityElement(props: ActivitiyElementProps) {
props.activity.activity_uuid,
access_token
)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
toast.dismiss(toast_loading)
toast.success('The activity has been updated successfully')
await revalidateTags(['courses'], props.orgslug)
@ -103,7 +105,7 @@ function ActivityElement(props: ActivitiyElementProps) {
try {
await updateActivity(modifiedActivityCopy, activityUUID, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
toast.success('Activity name updated successfully')
router.refresh()

View file

@ -18,6 +18,7 @@ import { useRouter } from 'next/navigation'
import { getAPIUrl } from '@services/config/config'
import { mutate } from 'swr'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useCourse } from '@components/Contexts/CourseContext'
type ChapterElementProps = {
chapter: any
@ -41,12 +42,14 @@ function ChapterElement(props: ChapterElementProps) {
const [selectedChapter, setSelectedChapter] = React.useState<
string | undefined
>(undefined)
const course = useCourse() as any;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
const router = useRouter()
const deleteChapterUI = async () => {
await deleteChapter(props.chapter.id, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
}
@ -57,7 +60,7 @@ function ChapterElement(props: ChapterElementProps) {
name: modifiedChapter.chapterName,
}
await updateChapter(chapterId, modifiedChapterCopy, access_token)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
}

View file

@ -50,7 +50,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
const course = useCourse() as any
const course_structure = course ? course.courseStructure : {}
const course_uuid = course ? course.courseStructure.course_uuid : ''
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
// New Chapter creation
const [newChapterModal, setNewChapterModal] = useState(false)
@ -61,7 +61,7 @@ const EditCourseStructure = (props: EditCourseStructureProps) => {
// Submit new chapter
const submitChapter = async (chapter: any) => {
await createChapter(chapter,access_token)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
setNewChapterModal(false)

View file

@ -32,6 +32,7 @@ import TableHeader from '@tiptap/extension-table-header'
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'
interface Editor {
content: string
@ -57,7 +58,18 @@ function Canva(props: Editor) {
const editor: any = useEditor({
editable: isEditable,
extensions: [
StarterKit,
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'bullet-list',
},
},
orderedList: {
HTMLAttributes: {
class: 'ordered-list',
},
},
}),
NoTextInput,
// Custom Extensions
InfoCallout.configure({
@ -112,6 +124,7 @@ function Canva(props: Editor) {
Table.configure({
resizable: true,
}),
getLinkExtension(),
TableRow,
TableHeader,
TableCell,
@ -194,10 +207,30 @@ const CanvaWrapper = styled.div`
margin-bottom: 10px;
}
// Link styling
a {
color: #2563eb;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #1d4ed8;
text-decoration: none;
}
}
ul,
ol {
padding: 0 1rem;
padding-left: 20px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}

View file

@ -3,17 +3,71 @@ import YouTube from 'react-youtube'
import { getActivityMediaDirectory } from '@services/media/media'
import { useOrg } from '@components/Contexts/OrgContext'
function VideoActivity({ activity, course }: { activity: any; course: any }) {
interface VideoDetails {
startTime?: number
endTime?: number | null
autoplay?: boolean
muted?: boolean
}
interface VideoActivityProps {
activity: {
activity_sub_type: string
activity_uuid: string
content: {
filename?: string
uri?: string
}
details?: VideoDetails
}
course: {
course_uuid: string
}
}
function VideoActivity({ activity, course }: VideoActivityProps) {
const org = useOrg() as any
const [videoId, setVideoId] = React.useState('')
const videoRef = React.useRef<HTMLVideoElement>(null)
React.useEffect(() => {
if (activity && activity.content && activity.content.uri) {
var getYouTubeID = require('get-youtube-id');
if (activity?.content?.uri) {
var getYouTubeID = require('get-youtube-id')
setVideoId(getYouTubeID(activity.content.uri))
}
}, [activity, org])
const getVideoSrc = () => {
if (!activity.content?.filename) return ''
return getActivityMediaDirectory(
org?.org_uuid,
course?.course_uuid,
activity.activity_uuid,
activity.content.filename,
'video'
)
}
// Handle native video time update
const handleTimeUpdate = () => {
const video = videoRef.current
if (video && activity.details?.endTime) {
if (video.currentTime >= activity.details.endTime) {
video.pause()
}
}
}
// Handle native video load
const handleVideoLoad = () => {
const video = videoRef.current
if (video && activity.details) {
video.currentTime = activity.details.startTime || 0
video.autoplay = activity.details.autoplay || false
video.muted = activity.details.muted || false
}
}
return (
<div className="w-full max-w-full px-2 sm:px-4">
{activity && (
@ -22,15 +76,12 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
<div className="my-3 md:my-5 w-full">
<div className="relative w-full aspect-video rounded-lg overflow-hidden ring-1 ring-gray-300/30 dark:ring-gray-600/30 sm:ring-gray-200/10 sm:dark:ring-gray-700/20 shadow-xs sm:shadow-none">
<video
ref={videoRef}
className="w-full h-full object-cover"
controls
src={getActivityMediaDirectory(
org?.org_uuid,
course?.course_uuid,
activity.activity_uuid,
activity.content?.filename,
'video'
)}
src={getVideoSrc()}
onLoadedMetadata={handleVideoLoad}
onTimeUpdate={handleTimeUpdate}
></video>
</div>
</div>
@ -44,7 +95,10 @@ function VideoActivity({ activity, course }: { activity: any; course: any }) {
width: '100%',
height: '100%',
playerVars: {
autoplay: 0,
autoplay: activity.details?.autoplay ? 1 : 0,
mute: activity.details?.muted ? 1 : 0,
start: activity.details?.startTime || 0,
end: activity.details?.endTime || undefined
},
}}
videoId={videoId}

View file

@ -32,7 +32,8 @@ import TableRow from '@tiptap/extension-table-row'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import Link from 'next/link'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getLinkExtension } from './EditorConf'
import { Link as LinkExtension } from '@tiptap/extension-link'
// Lowlight
import { common, createLowlight } from 'lowlight'
@ -95,7 +96,18 @@ function Editor(props: Editor) {
const editor: any = useEditor({
editable: true,
extensions: [
StarterKit,
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: 'bullet-list',
},
},
orderedList: {
HTMLAttributes: {
class: 'ordered-list',
},
},
}),
InfoCallout.configure({
editable: true,
}),
@ -151,6 +163,7 @@ function Editor(props: Editor) {
TableRow,
TableHeader,
TableCell,
getLinkExtension(),
],
content: props.content,
immediatelyRender: false,
@ -204,7 +217,7 @@ function Editor(props: Editor) {
props.org?.org_uuid,
props.course.course_uuid,
props.course.thumbnail_image
) : getUriWithOrg(props.org?.slug,'/empty_thumbnail.png')}`}
) : getUriWithOrg(props.org?.slug, '/empty_thumbnail.png')}`}
alt=""
></EditorInfoThumbnail>
</Link>
@ -459,6 +472,19 @@ export const EditorContentWrapper = styled.div`
margin-bottom: 10px;
}
// Link styling
a {
color: #2563eb;
text-decoration: underline;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: #1d4ed8;
text-decoration: none;
}
}
padding-left: 20px;
padding-right: 20px;
padding-bottom: 20px;
@ -564,6 +590,13 @@ export const EditorContentWrapper = styled.div`
ol {
padding: 0 1rem;
padding-left: 20px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}

View file

@ -0,0 +1,59 @@
import { Link as LinkExtension } from '@tiptap/extension-link'
export const getLinkExtension = () => {
return LinkExtension.configure({
openOnClick: true,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
isAllowedUri: (url: string, ctx: any) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)
// use default validation
if (!ctx.defaultValidate(parsedUrl.href)) {
return false
}
// disallowed protocols
const disallowedProtocols = ['ftp', 'file', 'mailto']
const protocol = parsedUrl.protocol.replace(':', '')
if (disallowedProtocols.includes(protocol)) {
return false
}
// only allow protocols specified in ctx.protocols
const allowedProtocols = ctx.protocols.map((p: any) => (typeof p === 'string' ? p : p.scheme))
if (!allowedProtocols.includes(protocol)) {
return false
}
// all checks have passed
return true
} catch {
return false
}
},
shouldAutoLink: (url: string) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`https://${url}`)
// only auto-link if the domain is not in the disallowed list
const disallowedDomains = ['example-no-autolink.com', 'another-no-autolink.com']
const domain = parsedUrl.hostname
return !disallowedDomains.includes(domain)
} catch {
return false
}
},
})
}

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react'
import styled from 'styled-components'
import { CheckIcon, Cross2Icon } from '@radix-ui/react-icons'
interface LinkInputTooltipProps {
onSave: (url: string) => void
onCancel: () => void
currentUrl?: string
}
const LinkInputTooltip: React.FC<LinkInputTooltipProps> = ({ onSave, onCancel, currentUrl }) => {
const [url, setUrl] = useState(currentUrl || '')
useEffect(() => {
setUrl(currentUrl || '')
}, [currentUrl])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (url) {
// Ensure the URL has a protocol
const formattedUrl = url.startsWith('http://') || url.startsWith('https://')
? url
: `https://${url}`
onSave(formattedUrl)
}
}
return (
<TooltipContainer>
<Form onSubmit={handleSubmit}>
<Input
type="text"
placeholder="Enter URL"
value={url}
onChange={(e) => setUrl(e.target.value)}
autoFocus
/>
<ButtonGroup>
<SaveButton type="submit" disabled={!url}>
<CheckIcon />
</SaveButton>
<CancelButton type="button" onClick={onCancel}>
<Cross2Icon />
</CancelButton>
</ButtonGroup>
</Form>
</TooltipContainer>
)
}
const TooltipContainer = styled.div`
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid rgba(217, 217, 217, 0.5);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
padding: 8px;
margin-top: 4px;
`
const Form = styled.form`
display: flex;
align-items: center;
gap: 4px;
`
const Input = styled.input`
padding: 4px 8px;
border: 1px solid rgba(217, 217, 217, 0.5);
border-radius: 4px;
font-size: 12px;
width: 200px;
&:focus {
outline: none;
border-color: rgba(217, 217, 217, 0.8);
}
`
const ButtonGroup = styled.div`
display: flex;
gap: 2px;
`
const Button = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
border-radius: 4px;
cursor: pointer;
background: rgba(217, 217, 217, 0.24);
transition: background 0.2s;
&:hover {
background: rgba(217, 217, 217, 0.48);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`
const SaveButton = styled(Button)`
color: #4CAF50;
`
const CancelButton = styled(Button)`
color: #F44336;
`
export default LinkInputTooltip

View file

@ -23,6 +23,7 @@ import {
FileText,
ImagePlus,
Lightbulb,
Link2,
MousePointerClick,
Sigma,
Table,
@ -30,30 +31,24 @@ import {
Tags,
User,
Video,
List,
ListOrdered,
} from 'lucide-react'
import { SiYoutube } from '@icons-pack/react-simple-icons'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import React from 'react'
import LinkInputTooltip from './LinkInputTooltip'
export const ToolbarButtons = ({ editor, props }: any) => {
const [showTableMenu, setShowTableMenu] = React.useState(false)
const [showListMenu, setShowListMenu] = React.useState(false)
const [showLinkInput, setShowLinkInput] = React.useState(false)
const linkButtonRef = React.useRef<HTMLDivElement>(null)
if (!editor) {
return null
}
// YouTube extension
const addYoutubeVideo = () => {
const url = prompt('Enter YouTube URL')
if (url) {
editor.commands.setYoutubeVideo({
src: url,
width: 640,
height: 480,
})
}
}
const tableOptions = [
{
@ -83,6 +78,74 @@ export const ToolbarButtons = ({ editor, props }: any) => {
}
]
const listOptions = [
{
label: 'Bullet List',
icon: <List size={15} />,
action: () => {
if (editor.isActive('bulletList')) {
editor.chain().focus().toggleBulletList().run()
} else {
editor.chain().focus().toggleOrderedList().run()
editor.chain().focus().toggleBulletList().run()
}
}
},
{
label: 'Ordered List',
icon: <ListOrdered size={15} />,
action: () => {
if (editor.isActive('orderedList')) {
editor.chain().focus().toggleOrderedList().run()
} else {
editor.chain().focus().toggleBulletList().run()
editor.chain().focus().toggleOrderedList().run()
}
}
}
]
const handleLinkClick = () => {
// Store the current selection
const { from, to } = editor.state.selection
if (editor.isActive('link')) {
const currentLink = editor.getAttributes('link')
setShowLinkInput(true)
} else {
setShowLinkInput(true)
}
// Restore the selection after a small delay to ensure the tooltip is rendered
setTimeout(() => {
editor.commands.setTextSelection({ from, to })
}, 0)
}
const getCurrentLinkUrl = () => {
if (editor.isActive('link')) {
return editor.getAttributes('link').href
}
return ''
}
const handleLinkSave = (url: string) => {
editor
.chain()
.focus()
.setLink({
href: url,
target: '_blank',
rel: 'noopener noreferrer'
})
.run()
setShowLinkInput(false)
}
const handleLinkCancel = () => {
setShowLinkInput(false)
}
return (
<ToolButtonsWrapper>
<ToolBtn onClick={() => editor.chain().focus().undo().run()}>
@ -109,12 +172,32 @@ export const ToolbarButtons = ({ editor, props }: any) => {
>
<StrikethroughIcon />
</ToolBtn>
<ToolBtn
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive('orderedList') ? 'is-active' : ''}
>
<ListBulletIcon />
</ToolBtn>
<ListMenuWrapper>
<ToolBtn
onClick={() => setShowListMenu(!showListMenu)}
className={showListMenu || editor.isActive('bulletList') || editor.isActive('orderedList') ? 'is-active' : ''}
>
<ListBulletIcon />
<ChevronDownIcon />
</ToolBtn>
{showListMenu && (
<ListDropdown>
{listOptions.map((option, index) => (
<ListMenuItem
key={index}
onClick={() => {
option.action()
setShowListMenu(false)
}}
className={editor.isActive(option.label === 'Bullet List' ? 'bulletList' : 'orderedList') ? 'is-active' : ''}
>
<span className="icon">{option.icon}</span>
<span className="label">{option.label}</span>
</ListMenuItem>
))}
</ListDropdown>
)}
</ListMenuWrapper>
<ToolSelect
value={
editor.isActive('heading', { level: 1 }) ? "1" :
@ -185,6 +268,24 @@ export const ToolbarButtons = ({ editor, props }: any) => {
<AlertTriangle size={15} />
</ToolBtn>
</ToolTip>
<ToolTip content={'Link'}>
<div style={{ position: 'relative' }}>
<ToolBtn
ref={linkButtonRef}
onClick={handleLinkClick}
className={editor.isActive('link') ? 'is-active' : ''}
>
<Link2 size={15} />
</ToolBtn>
{showLinkInput && (
<LinkInputTooltip
onSave={handleLinkSave}
onCancel={handleLinkCancel}
currentUrl={getCurrentLinkUrl()}
/>
)}
</div>
</ToolTip>
<ToolTip content={'Image'}>
<ToolBtn
onClick={() =>
@ -428,3 +529,48 @@ const TableMenuItem = styled.div`
font-family: 'DM Sans';
}
`
const ListMenuWrapper = styled.div`
position: relative;
display: inline-block;
`
const ListDropdown = styled.div`
position: absolute;
top: 100%;
left: 0;
background: white;
border: 1px solid rgba(217, 217, 217, 0.5);
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 1000;
min-width: 180px;
margin-top: 4px;
`
const ListMenuItem = styled.div`
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: rgba(217, 217, 217, 0.24);
}
&.is-active {
background: rgba(176, 176, 176, 0.5);
}
.icon {
margin-right: 8px;
display: flex;
align-items: center;
}
.label {
font-size: 12px;
font-family: 'DM Sans';
}
`

View file

@ -4,11 +4,11 @@ import VideoPageActivityImage from 'public//activities_types/video-page-activity
import DocumentPdfPageActivityImage from 'public//activities_types/documentpdf-page-activity.png'
import AssignmentActivityImage from 'public//activities_types/assignment-page-activity.png'
import DynamicCanvaModal from './NewActivityModal/DynamicCanva'
import VideoModal from './NewActivityModal/Video'
import DynamicCanvaModal from './NewActivityModal/DynamicActivityModal'
import VideoModal from './NewActivityModal/VideoActivityModal'
import Image from 'next/image'
import DocumentPdfModal from './NewActivityModal/DocumentPdf'
import Assignment from './NewActivityModal/Assignment'
import DocumentPdfModal from './NewActivityModal/DocumentActivityModal'
import Assignment from './NewActivityModal/AssignmentActivityModal'
function NewActivityModal({
closeModal,

View file

@ -1,199 +0,0 @@
import FormLayout, {
ButtonBlack,
Flex,
FormField,
FormLabel,
FormMessage,
Input,
} from '@components/Objects/StyledElements/Form/Form'
import React, { useState } from 'react'
import * as Form from '@radix-ui/react-form'
import BarLoader from 'react-spinners/BarLoader'
import { Youtube } from 'lucide-react'
import { constructAcceptValue } from '@/lib/constants';
const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm'])
interface ExternalVideoObject {
name: string
type: string
uri: string
chapter_id: string
}
function VideoModal({
submitFileActivity,
submitExternalVideo,
chapterId,
course,
}: any) {
const [video, setVideo] = React.useState(null) as any
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = React.useState('')
const [youtubeUrl, setYoutubeUrl] = React.useState('')
const [selectedView, setSelectedView] = React.useState('file') as any
const handleVideoChange = (event: React.ChangeEvent<any>) => {
setVideo(event.target.files[0])
}
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value)
}
const handleYoutubeUrlChange = (
event: React.ChangeEvent<HTMLInputElement>
) => {
setYoutubeUrl(event.target.value)
}
const handleSubmit = async (e: any) => {
e.preventDefault()
setIsSubmitting(true)
if (selectedView === 'file') {
let status = await submitFileActivity(
video,
'video',
{
name: name,
chapter_id: chapterId,
activity_type: 'TYPE_VIDEO',
activity_sub_type: 'SUBTYPE_VIDEO_HOSTED',
published_version: 1,
version: 1,
course_id: course.id,
},
chapterId
)
setIsSubmitting(false)
}
if (selectedView === 'youtube') {
let external_video_object: ExternalVideoObject = {
name,
type: 'youtube',
uri: youtubeUrl,
chapter_id: chapterId,
}
let status = await submitExternalVideo(
external_video_object,
'activity',
chapterId
)
setIsSubmitting(false)
}
}
/* TODO : implement some sort of progress bar for file uploads, it is not possible yet because i'm not using axios.
and the actual upload isn't happening here anyway, it's in the submitFileActivity function */
return (
<FormLayout onSubmit={handleSubmit}>
<FormField name="video-activity-name">
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
<FormLabel>Video name</FormLabel>
<FormMessage match="valueMissing">
Please provide a name for your video activity
</FormMessage>
</Flex>
<Form.Control asChild>
<Input onChange={handleNameChange} type="text" required />
</Form.Control>
</FormField>
<div className="flex flex-col rounded-md bg-gray-50 outline-dashed outline-gray-200">
<div className="">
<div className="flex m-4 justify-center space-x-2 mb-0">
<div
onClick={() => {
setSelectedView('file')
}}
className="rounded-full bg-slate-900 text-zinc-50 py-2 px-4 text-sm drop-shadow-md hover:cursor-pointer hover:bg-slate-700 "
>
Video upload
</div>
<div
onClick={() => {
setSelectedView('youtube')
}}
className="rounded-full bg-slate-900 text-zinc-50 py-2 px-4 text-sm drop-shadow-md hover:cursor-pointer hover:bg-slate-700"
>
YouTube Video
</div>
</div>
{selectedView === 'file' && (
<div className="p-4 justify-center m-auto align-middle">
<FormField name="video-activity-file">
<Flex
css={{
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
<FormLabel>Video file</FormLabel>
<FormMessage match="valueMissing">
Please provide a video for your activity
</FormMessage>
</Flex>
<Form.Control asChild>
<input accept={SUPPORTED_FILES} type="file" onChange={handleVideoChange} required />
</Form.Control>
</FormField>
</div>
)}
{selectedView === 'youtube' && (
<div className="p-4 justify-center m-auto align-middle">
<FormField name="video-activity-file">
<Flex
css={{
alignItems: 'baseline',
justifyContent: 'space-between',
}}
>
<FormLabel className="flex justify-center align-middle">
<Youtube className="m-auto pr-1" />
<span className="flex">YouTube URL</span>
</FormLabel>
<FormMessage match="valueMissing">
Please provide a video for your activity
</FormMessage>
</Flex>
<Form.Control asChild>
<Input
className="bg-white"
onChange={handleYoutubeUrlChange}
type="text"
required
/>
</Form.Control>
</FormField>
</div>
)}
</div>
</div>
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
<Form.Submit asChild>
<ButtonBlack
className="bg-black"
type="submit"
css={{ marginTop: 10 }}
>
{isSubmitting ? (
<BarLoader
cssOverride={{ borderRadius: 60 }}
width={60}
color="#ffffff"
/>
) : (
'Create activity'
)}
</ButtonBlack>
</Form.Submit>
</Flex>
</FormLayout>
)
}
export default VideoModal

View file

@ -0,0 +1,265 @@
import {
Button,
} from "@components/ui/button"
import {
Input
} from "@components/ui/input"
import { Label } from "@components/ui/label"
import React, { useState } from 'react'
import * as Form from '@radix-ui/react-form'
import BarLoader from 'react-spinners/BarLoader'
import { Youtube, Upload } from 'lucide-react'
import { constructAcceptValue } from '@/lib/constants'
const SUPPORTED_FILES = constructAcceptValue(['mp4', 'webm'])
interface VideoDetails {
startTime: number
endTime: number | null
autoplay: boolean
muted: boolean
}
interface ExternalVideoObject {
name: string
type: string
uri: string
chapter_id: string
details: VideoDetails
}
function VideoModal({
submitFileActivity,
submitExternalVideo,
chapterId,
course,
}: any) {
const [video, setVideo] = React.useState<File | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [name, setName] = React.useState('')
const [youtubeUrl, setYoutubeUrl] = React.useState('')
const [selectedView, setSelectedView] = React.useState<'file' | 'youtube'>('file')
const [videoDetails, setVideoDetails] = React.useState<VideoDetails>({
startTime: 0,
endTime: null,
autoplay: false,
muted: false
})
const handleVideoChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files?.[0]) {
setVideo(event.target.files[0])
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
try {
if (selectedView === 'file' && video) {
await submitFileActivity(
video,
'video',
{
name: name,
chapter_id: chapterId,
activity_type: 'TYPE_VIDEO',
activity_sub_type: 'SUBTYPE_VIDEO_HOSTED',
published_version: 1,
version: 1,
course_id: course.id,
details: videoDetails
},
chapterId
)
}
if (selectedView === 'youtube') {
const external_video_object: ExternalVideoObject = {
name,
type: 'youtube',
uri: youtubeUrl,
chapter_id: chapterId,
details: videoDetails
}
await submitExternalVideo(
external_video_object,
'activity',
chapterId
)
}
} finally {
setIsSubmitting(false)
}
}
const VideoSettingsForm = () => (
<div className="space-y-4 mt-4 p-4 bg-gray-50 rounded-lg">
<h3 className="font-medium text-gray-900 mb-3">Video Settings</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="start-time">Start Time (seconds)</Label>
<Input
id="start-time"
type="number"
min="0"
value={videoDetails.startTime}
onChange={(e) => setVideoDetails({
...videoDetails,
startTime: Math.max(0, parseInt(e.target.value) || 0)
})}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="end-time">End Time (seconds, optional)</Label>
<Input
id="end-time"
type="number"
min={videoDetails.startTime + 1}
value={videoDetails.endTime || ''}
onChange={(e) => setVideoDetails({
...videoDetails,
endTime: e.target.value ? parseInt(e.target.value) : null
})}
placeholder="Leave empty for full duration"
/>
</div>
</div>
<div className="flex items-center space-x-6 mt-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={videoDetails.autoplay}
onChange={(e) => setVideoDetails({
...videoDetails,
autoplay: e.target.checked
})}
className="rounded border-gray-300 text-black focus:ring-black"
/>
<span className="text-sm text-gray-700">Autoplay video</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={videoDetails.muted}
onChange={(e) => setVideoDetails({
...videoDetails,
muted: e.target.checked
})}
className="rounded border-gray-300 text-black focus:ring-black"
/>
<span className="text-sm text-gray-700">Start muted</span>
</label>
</div>
</div>
)
return (
<Form.Root onSubmit={handleSubmit}>
<div>
<Label htmlFor="video-activity-name">Activity Name</Label>
<Input
id="video-activity-name"
value={name}
onChange={(e) => setName(e.target.value)}
type="text"
required
placeholder="Enter activity name..."
/>
</div>
<div className="mt-4 rounded-lg border border-gray-200">
<div className="grid grid-cols-2 gap-0">
<button
type="button"
onClick={() => setSelectedView('file')}
className={`flex items-center justify-center p-4 gap-2 ${
selectedView === 'file'
? 'bg-gray-100 border-b-2 border-black'
: 'hover:bg-gray-50 border-b border-gray-200'
}`}
>
<Upload size={18} />
<span>Upload Video</span>
</button>
<button
type="button"
onClick={() => setSelectedView('youtube')}
className={`flex items-center justify-center p-4 gap-2 ${
selectedView === 'youtube'
? 'bg-gray-100 border-b-2 border-black'
: 'hover:bg-gray-50 border-b border-gray-200'
}`}
>
<Youtube size={18} />
<span>YouTube Video</span>
</button>
</div>
<div className="p-6">
{selectedView === 'file' && (
<div className="space-y-4">
<div>
<Label htmlFor="video-activity-file">Video File</Label>
<div className="mt-2">
<input
id="video-activity-file"
type="file"
accept={SUPPORTED_FILES}
onChange={handleVideoChange}
required
className="w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-black file:text-white hover:file:bg-gray-800"
/>
</div>
</div>
<VideoSettingsForm />
</div>
)}
{selectedView === 'youtube' && (
<div className="space-y-4">
<div>
<Label htmlFor="youtube-url">YouTube URL</Label>
<Input
id="youtube-url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
type="text"
required
placeholder="https://youtube.com/watch?v=..."
/>
</div>
<VideoSettingsForm />
</div>
)}
</div>
</div>
<div className="flex justify-end mt-6">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? (
<BarLoader
cssOverride={{ borderRadius: 60 }}
width={60}
color="#ffffff"
/>
) : (
'Create Activity'
)}
</Button>
</div>
</Form.Root>
)
}
export default VideoModal

View file

@ -4,7 +4,7 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClient
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getUriWithOrg } from '@services/config/config'
import { deleteCourseFromBackend } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'
import { revalidateTags } from '@services/utils/ts/requests'
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
@ -12,6 +12,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import React from 'react'
import toast from 'react-hot-toast'
import UserAvatar from '@components/Objects/UserAvatar'
import {
DropdownMenu,
DropdownMenuContent,
@ -25,6 +26,19 @@ type Course = {
description: string
thumbnail_image: string
org_id: string
update_date: string
authors?: Array<{
user: {
id: string
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
}>
}
type PropsType = {
@ -40,6 +54,11 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
const org = useOrg() as any
const session = useLHSession() as any
const activeAuthors = course.authors?.filter(author => author.authorship_status === 'ACTIVE') || []
const displayedAuthors = activeAuthors.slice(0, 3)
const hasMoreAuthors = activeAuthors.length > 3
const remainingAuthorsCount = activeAuthors.length - 3
const deleteCourse = async () => {
const toastId = toast.loading('Deleting course...')
try {
@ -59,7 +78,7 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
: '../empty_thumbnail.png'
return (
<div className="relative">
<div className="relative flex flex-col bg-white rounded-xl nice-shadow overflow-hidden min-w-[280px] w-full max-w-sm shrink-0">
<AdminEditOptions
course={course}
orgSlug={orgslug}
@ -67,13 +86,65 @@ function CourseThumbnail({ course, orgslug, customLink }: PropsType) {
/>
<Link prefetch href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}>
<div
className="inset-0 ring-1 ring-inset ring-black/10 rounded-xl shadow-xl w-full aspect-video bg-cover bg-center"
className="inset-0 ring-1 ring-inset ring-black/10 rounded-t-xl w-full aspect-video bg-cover bg-center"
style={{ backgroundImage: `url(${thumbnailImage})` }}
/>
</Link>
<div className='flex flex-col w-full pt-3 space-y-2'>
<h2 className="font-bold text-gray-800 line-clamp-2 leading-tight text-lg capitalize">{course.name}</h2>
<p className='text-sm text-gray-700 leading-normal line-clamp-3'>{course.description}</p>
<div className='flex flex-col w-full p-4 space-y-3'>
<div className="space-y-2">
<h2 className="font-bold text-gray-800 leading-tight text-base min-h-[2.75rem] line-clamp-2">{course.name}</h2>
<p className='text-xs text-gray-700 leading-normal min-h-[3.75rem] line-clamp-3'>{course.description}</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{course.update_date && (
<div className="inline-flex h-5 min-w-[140px] items-center justify-center px-2 rounded-md bg-gray-100/80 border border-gray-200">
<span className="text-[10px] font-medium text-gray-600 truncate">
Updated {new Date(course.update_date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
)}
{displayedAuthors.length > 0 && (
<div className="flex -space-x-4 items-center">
{displayedAuthors.map((author, index) => (
<div
key={author.user.user_uuid}
className="relative"
style={{ zIndex: displayedAuthors.length - index }}
>
<UserAvatar
border="border-2"
rounded="rounded-full"
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={32}
showProfilePopup={true}
userId={author.user.id}
/>
</div>
))}
{hasMoreAuthors && (
<div
className="relative -ml-1"
style={{ zIndex: 0 }}
>
<div className="flex items-center justify-center w-[32px] h-[32px] text-[11px] font-medium text-gray-600 bg-gray-100 border-2 border-white rounded-full">
+{remainingAuthorsCount}
</div>
</div>
)}
</div>
)}
</div>
<Link
prefetch
href={customLink ? customLink : getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
className="inline-flex items-center justify-center w-full px-3 py-1.5 bg-black text-white text-xs font-medium rounded-lg hover:bg-gray-800 transition-colors"
>
Start Learning
</Link>
</div>
</div>
)

View file

@ -4,7 +4,7 @@ import AuthenticatedClientElement from '@components/Security/AuthenticatedClient
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getUriWithOrg } from '@services/config/config'
import { deleteCourseFromBackend } from '@services/courses/courses'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'
import { revalidateTags } from '@services/utils/ts/requests'
import { BookMinus, FilePenLine, Settings2, MoreVertical } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
@ -12,6 +12,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import React from 'react'
import toast from 'react-hot-toast'
import UserAvatar from '@components/Objects/UserAvatar'
import {
DropdownMenu,
DropdownMenuContent,
@ -26,6 +27,18 @@ type Course = {
thumbnail_image: string
org_id: string
update_date: string
authors?: Array<{
user: {
id: string
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER'
authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING'
}>
}
type PropsType = {
@ -94,6 +107,11 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
const org = useOrg() as any
const session = useLHSession() as any
const activeAuthors = course.authors?.filter(author => author.authorship_status === 'ACTIVE') || []
const displayedAuthors = activeAuthors.slice(0, 3)
const hasMoreAuthors = activeAuthors.length > 3
const remainingAuthorsCount = activeAuthors.length - 3
const deleteCourse = async () => {
const toastId = toast.loading('Deleting course...')
try {
@ -131,7 +149,7 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
<p className='text-xs text-gray-700 leading-normal min-h-[3.75rem] line-clamp-3'>{course.description}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
{course.update_date && (
<div className="inline-flex h-5 min-w-[140px] items-center justify-center px-2 rounded-md bg-gray-100/80 border border-gray-200">
<span className="text-[10px] font-medium text-gray-600 truncate">
@ -139,6 +157,38 @@ const CourseThumbnailLanding: React.FC<PropsType> = ({ course, orgslug, customLi
</span>
</div>
)}
{displayedAuthors.length > 0 && (
<div className="flex -space-x-4 items-center">
{displayedAuthors.map((author, index) => (
<div
key={author.user.user_uuid}
className="relative"
style={{ zIndex: displayedAuthors.length - index }}
>
<UserAvatar
border="border-2"
rounded="rounded-full"
avatar_url={author.user.avatar_image ? getUserAvatarMediaDirectory(author.user.user_uuid, author.user.avatar_image) : ''}
predefined_avatar={author.user.avatar_image ? undefined : 'empty'}
width={32}
showProfilePopup={true}
userId={author.user.id}
/>
</div>
))}
{hasMoreAuthors && (
<div
className="relative -ml-1"
style={{ zIndex: 0 }}
>
<div className="flex items-center justify-center w-[32px] h-[32px] text-[11px] font-medium text-gray-600 bg-gray-100 border-2 border-white rounded-full">
+{remainingAuthorsCount}
</div>
</div>
)}
</div>
)}
</div>
<Link

View file

@ -0,0 +1,167 @@
'use client'
import { useMediaQuery } from 'usehooks-ts'
import { BookOpenCheck, Check, FileText, Folder, Layers, ListTree, Video, X, StickyNote, Backpack, ArrowRight } from 'lucide-react'
import { getUriWithOrg } from '@services/config/config'
import Link from 'next/link'
import React from 'react'
interface ActivityChapterDropdownProps {
course: any
currentActivityId: string
orgslug: string
}
export default function ActivityChapterDropdown(props: ActivityChapterDropdownProps): React.ReactNode {
const [isOpen, setIsOpen] = React.useState(false);
const dropdownRef = React.useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery('(max-width: 768px)');
// Close dropdown when clicking outside
React.useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
// Function to get the appropriate icon for activity type
const getActivityTypeIcon = (activityType: string) => {
switch (activityType) {
case 'TYPE_VIDEO':
return <Video size={10} />;
case 'TYPE_DOCUMENT':
return <FileText size={10} />;
case 'TYPE_DYNAMIC':
return <StickyNote size={10} />;
case 'TYPE_ASSIGNMENT':
return <Backpack size={10} />;
default:
return <FileText size={10} />;
}
};
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';
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}
className="flex items-center justify-center bg-white nice-shadow p-2 rounded-full cursor-pointer"
aria-label="View all activities"
title="View all activities"
>
<ListTree size={16} className="text-gray-700" />
</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="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
onClick={() => setIsOpen(false)}
className="text-gray-500 hover:text-gray-700 p-1 rounded-full hover:bg-gray-100 cursor-pointer"
>
<X size={14} />
</button>
</div>
<div className="py-0.5">
{props.course.chapters.map((chapter: any) => (
<div key={chapter.id} className="mb-1">
<div className="px-3 py-1.5 text-sm font-medium text-gray-600 bg-gray-50 border-y border-gray-100 flex items-center">
<div className="flex items-center space-x-1.5">
<Folder size={14} className="text-gray-400" />
<span>{chapter.name}</span>
</div>
</div>
<div className="py-0.5">
{chapter.activities.map((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
const isCurrent = cleanActivityUuid === props.currentActivityId.replace('activity_', '');
return (
<Link
key={activity.id}
href={getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${cleanActivityUuid}`}
prefetch={false}
onClick={() => setIsOpen(false)}
>
<div
className={`group hover:bg-neutral-50 transition-colors px-3 py-2 ${
isCurrent ? 'bg-neutral-50 border-l-2 border-neutral-300 pl-2.5 font-medium' : ''
}`}
>
<div className="flex space-x-2 items-center">
<div className="flex items-center">
{props.course.trail?.runs?.find(
(run: any) => run.course_id === props.course.id
)?.steps?.find(
(step: any) => (step.activity_id === activity.id || step.activity_id === activity.activity_uuid) && step.complete === true
) ? (
<div className="relative cursor-pointer">
<Check size={14} className="stroke-[2.5] text-teal-600" />
</div>
) : (
<div className="text-neutral-300 cursor-pointer">
<Check size={14} className="stroke-[2]" />
</div>
)}
</div>
<div className="flex flex-col grow">
<div className="flex items-center space-x-1.5 w-full">
<p className="text-sm font-medium text-neutral-600 group-hover:text-neutral-800 transition-colors">
{activity.name}
</p>
{isCurrent && (
<div className="flex items-center space-x-1 text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded-full text-[10px] font-medium animate-pulse">
<span>Current</span>
</div>
)}
</div>
<div className="flex items-center space-x-1 mt-0.5 text-neutral-400">
{getActivityTypeIcon(activity.activity_type)}
<span className="text-[10px] font-medium">
{getActivityTypeLabel(activity.activity_type)}
</span>
</div>
</div>
<div className="text-neutral-300 group-hover:text-neutral-400 transition-colors cursor-pointer">
<ArrowRight size={12} />
</div>
</div>
</div>
</Link>
);
})}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,211 @@
'use client'
import { useRouter } from 'next/navigation'
import { useMediaQuery } from 'usehooks-ts'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { getUriWithOrg } from '@services/config/config'
import React from 'react'
interface ActivityNavigationProps {
course: any
currentActivityId: string
orgslug: string
}
export default function ActivityNavigation(props: ActivityNavigationProps): React.ReactNode {
const router = useRouter();
const isMobile = useMediaQuery('(max-width: 768px)');
const [isBottomNavVisible, setIsBottomNavVisible] = React.useState(true);
const bottomNavRef = React.useRef<HTMLDivElement>(null);
const [navWidth, setNavWidth] = React.useState<number | null>(null);
// Function to find the current activity's position in the course
const findActivityPosition = () => {
let allActivities: any[] = [];
let currentIndex = -1;
// Flatten all activities from all chapters
props.course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
allActivities.push({
...activity,
cleanUuid: cleanActivityUuid,
chapterName: chapter.name
});
// Check if this is the current activity
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
currentIndex = allActivities.length - 1;
}
});
});
return { allActivities, currentIndex };
};
const { allActivities, currentIndex } = findActivityPosition();
// Get previous and next activities
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
// Navigate to an activity
const navigateToActivity = (activity: any) => {
if (!activity) return;
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
};
// Set up intersection observer to detect when bottom nav is out of viewport
// and measure the width of the bottom navigation
React.useEffect(() => {
if (!bottomNavRef.current) return;
// Update width when component mounts and on window resize
const updateWidth = () => {
if (bottomNavRef.current) {
setNavWidth(bottomNavRef.current.offsetWidth);
}
};
// Initial width measurement
updateWidth();
// Set up resize listener
window.addEventListener('resize', updateWidth);
const observer = new IntersectionObserver(
([entry]) => {
setIsBottomNavVisible(entry.isIntersecting);
},
{ threshold: 0.1 }
);
observer.observe(bottomNavRef.current);
return () => {
window.removeEventListener('resize', updateWidth);
if (bottomNavRef.current) {
observer.unobserve(bottomNavRef.current);
}
};
}, []);
// Navigation buttons component - reused for both top and bottom
const NavigationButtons = ({ isFloating = false }) => (
<div className={`${isFloating ? 'flex justify-between' : 'grid grid-cols-3'} items-center w-full`}>
{isFloating ? (
// Floating navigation - original flex layout
<>
<button
onClick={() => navigateToActivity(prevActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
prevActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!prevActivity}
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
>
<ChevronLeft size={20} className="text-gray-800 shrink-0" />
<div className="flex flex-col items-start">
<span className="text-xs text-gray-500">Previous</span>
<span className="text-sm capitalize font-semibold text-left">
{prevActivity ? prevActivity.name : 'No previous activity'}
</span>
</div>
</button>
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1.5 p-2 rounded-md transition-all duration-200 cursor-pointer ${
nextActivity
? 'text-gray-700'
: 'opacity-50 text-gray-400 cursor-not-allowed'
}`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>
<div className="flex flex-col items-end">
<span className="text-xs text-gray-500">Next</span>
<span className="text-sm capitalize font-semibold text-right">
{nextActivity ? nextActivity.name : 'No next activity'}
</span>
</div>
<ChevronRight size={20} className="text-gray-800 shrink-0" />
</button>
</>
) : (
// Regular navigation - grid layout with centered counter
<>
<div className="justify-self-start">
<button
onClick={() => navigateToActivity(prevActivity)}
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
prevActivity
? 'bg-white nice-shadow text-gray-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!prevActivity}
title={prevActivity ? `Previous: ${prevActivity.name}` : 'No previous activity'}
>
<ChevronLeft size={16} className="shrink-0" />
<div className="flex flex-col items-start">
<span className="text-xs text-gray-500">Previous</span>
<span className="text-sm capitalize font-semibold text-left">
{prevActivity ? prevActivity.name : 'No previous activity'}
</span>
</div>
</button>
</div>
<div className="text-sm text-gray-500 justify-self-center">
{currentIndex + 1} of {allActivities.length}
</div>
<div className="justify-self-end">
<button
onClick={() => navigateToActivity(nextActivity)}
className={`flex items-center space-x-1.5 px-3.5 py-2 rounded-md transition-all duration-200 cursor-pointer ${
nextActivity
? 'bg-white nice-shadow text-gray-700'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!nextActivity}
title={nextActivity ? `Next: ${nextActivity.name}` : 'No next activity'}
>
<div className="flex flex-col items-end">
<span className="text-xs text-gray-500">Next</span>
<span className="text-sm capitalize font-semibold text-right">
{nextActivity ? nextActivity.name : 'No next activity'}
</span>
</div>
<ChevronRight size={16} className="shrink-0" />
</button>
</div>
</>
)}
</div>
);
return (
<>
{/* Bottom navigation (in-place) */}
<div ref={bottomNavRef} className="mt-6 mb-2 w-full">
<NavigationButtons isFloating={false} />
</div>
{/* Floating bottom navigation - shown when bottom nav is not visible */}
{!isBottomNavVisible && (
<div className="fixed bottom-8 left-1/2 transform -translate-x-1/2 z-50 w-[85%] sm:w-auto sm:min-w-[350px] max-w-lg transition-all duration-300 ease-in-out">
<div
className="bg-white/90 backdrop-blur-xl rounded-full py-1.5 px-2.5 shadow-xs animate-in fade-in slide-in-from-bottom duration-300"
>
<NavigationButtons isFloating={true} />
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,79 @@
import React from 'react';
import ReactConfetti from 'react-confetti';
import { Trophy, ArrowLeft } from 'lucide-react';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { useWindowSize } from 'usehooks-ts';
import { useOrg } from '@components/Contexts/OrgContext';
interface CourseEndViewProps {
courseName: string;
orgslug: string;
courseUuid: string;
thumbnailImage: string;
}
const CourseEndView: React.FC<CourseEndViewProps> = ({ courseName, orgslug, courseUuid, thumbnailImage }) => {
const { width, height } = useWindowSize();
const org = useOrg() as any;
return (
<div className="min-h-[70vh] flex flex-col items-center justify-center text-center px-4 relative overflow-hidden">
<div className="fixed inset-0 pointer-events-none">
<ReactConfetti
width={width}
height={height}
numberOfPieces={200}
recycle={false}
colors={['#6366f1', '#10b981', '#3b82f6']}
/>
</div>
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-2xl w-full space-y-6 relative z-10">
<div className="flex flex-col items-center space-y-6">
{thumbnailImage && (
<img
className="w-[200px] h-[114px] rounded-lg shadow-md object-cover"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
courseUuid,
thumbnailImage
)}`}
alt={courseName}
/>
)}
<div className="bg-emerald-100 p-4 rounded-full">
<Trophy className="w-16 h-16 text-emerald-600" />
</div>
</div>
<h1 className="text-4xl font-bold text-gray-900">
Congratulations! 🎉
</h1>
<p className="text-xl text-gray-600">
You've successfully completed
<span className="font-semibold text-gray-900"> {courseName}</span>
</p>
<p className="text-gray-500">
Your dedication and hard work have paid off. You've mastered all the content in this course.
</p>
<div className="pt-6">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseUuid.replace('course_', '')}`}
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div>
</div>
</div>
);
};
export default CourseEndView;

View file

@ -0,0 +1,187 @@
'use client'
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 ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import ActivityChapterDropdown from './ActivityChapterDropdown'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
import { useOrg } from '@components/Contexts/OrgContext'
interface FixedActivitySecondaryBarProps {
course: any
currentActivityId: string
orgslug: string
activity: any
}
export default function FixedActivitySecondaryBar(props: FixedActivitySecondaryBarProps): React.ReactNode {
const router = useRouter();
const [isScrolled, setIsScrolled] = useState(false);
const [shouldShow, setShouldShow] = useState(false);
const mainActivityInfoRef = useRef<HTMLDivElement | null>(null);
const org = useOrg() as any;
// Function to find the current activity's position in the course
const findActivityPosition = () => {
let allActivities: any[] = [];
let currentIndex = -1;
// Flatten all activities from all chapters
props.course.chapters.forEach((chapter: any) => {
chapter.activities.forEach((activity: any) => {
const cleanActivityUuid = activity.activity_uuid?.replace('activity_', '');
allActivities.push({
...activity,
cleanUuid: cleanActivityUuid,
chapterName: chapter.name
});
// Check if this is the current activity
if (cleanActivityUuid === props.currentActivityId.replace('activity_', '')) {
currentIndex = allActivities.length - 1;
}
});
});
return { allActivities, currentIndex };
};
const { allActivities, currentIndex } = findActivityPosition();
// Get previous and next activities
const prevActivity = currentIndex > 0 ? allActivities[currentIndex - 1] : null;
const nextActivity = currentIndex < allActivities.length - 1 ? allActivities[currentIndex + 1] : null;
// Navigate to an activity
const navigateToActivity = (activity: any) => {
if (!activity) return;
const cleanCourseUuid = props.course.course_uuid?.replace('course_', '');
router.push(getUriWithOrg(props.orgslug, '') + `/course/${cleanCourseUuid}/activity/${activity.cleanUuid}`);
};
// Handle scroll and intersection observer
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 0);
};
// Set up intersection observer for the main activity info
const observer = new IntersectionObserver(
([entry]) => {
// Show the fixed bar when the main info is not visible
setShouldShow(!entry.isIntersecting);
},
{
threshold: [0, 0.1, 1],
rootMargin: '-80px 0px 0px 0px' // Increased margin to account for the header
}
);
// Start observing the main activity info section with a slight delay to ensure DOM is ready
setTimeout(() => {
const mainActivityInfo = document.querySelector('.activity-info-section');
if (mainActivityInfo) {
mainActivityInfoRef.current = mainActivityInfo as HTMLDivElement;
observer.observe(mainActivityInfo);
}
}, 100);
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
if (mainActivityInfoRef.current) {
observer.unobserve(mainActivityInfoRef.current);
}
};
}, []);
return (
<>
{shouldShow && (
<div
className={`fixed top-[60px] left-0 right-0 z-40 bg-white/90 backdrop-blur-xl transition-all duration-300 animate-in fade-in slide-in-from-top ${
isScrolled ? 'nice-shadow' : ''
}`}
>
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16 py-2">
{/* Left Section - Course Info and Navigation */}
<div className="flex items-center space-x-2 sm:space-x-4 min-w-0 flex-shrink">
<img
className="w-[35px] sm:w-[45px] h-[20px] sm:h-[26px] rounded-md object-cover flex-shrink-0"
src={`${getCourseThumbnailMediaDirectory(
org?.org_uuid,
props.course.course_uuid,
props.course.thumbnail_image
)}`}
alt=""
/>
<ActivityChapterDropdown
course={props.course}
currentActivityId={props.currentActivityId}
orgslug={props.orgslug}
/>
<div className="flex flex-col -space-y-0.5 min-w-0 hidden sm:block">
<p className="text-sm font-medium text-gray-500">Course</p>
<h1 className="font-semibold text-gray-900 text-base truncate">
{props.course.name}
</h1>
</div>
</div>
{/* Right Section - Navigation Controls */}
<div className="flex items-center flex-shrink-0">
<div className="flex items-center space-x-2 sm:space-x-3">
<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 ${
nextActivity
? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
: 'text-gray-300 cursor-not-allowed'
}`}
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>
)}
</>
);
}

View file

@ -18,6 +18,7 @@ import { useRouter } from 'next/navigation'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { deleteActivity, updateActivity } from '@services/courses/activities'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useCourse } from '@components/Contexts/CourseContext'
interface ModifiedActivityInterface {
activityId: string
@ -33,10 +34,12 @@ function Activity(props: any) {
const [selectedActivity, setSelectedActivity] = React.useState<
string | undefined
>(undefined)
const course = useCourse() as any;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
async function removeActivity() {
await deleteActivity(props.activity.id, session.data?.tokens?.access_token)
mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
}
@ -52,7 +55,7 @@ function Activity(props: any) {
}
await updateActivity(modifiedActivityCopy, activityId, session.data?.tokens?.access_token)
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}`)
await mutate(`${getAPIUrl()}chapters/meta/course_${props.courseid}?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
}

View file

@ -10,7 +10,7 @@ import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
import { revalidateTags } from '@services/utils/ts/requests'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useCourse } from '@components/Contexts/CourseContext'
interface ModifiedChapterInterface {
chapterId: string
chapterName: string
@ -25,6 +25,8 @@ function Chapter(props: any) {
const [selectedChapter, setSelectedChapter] = React.useState<
string | undefined
>(undefined)
const course = useCourse() as any;
const withUnpublishedActivities = course ? course.withUnpublishedActivities : false
async function updateChapterName(chapterId: string) {
if (modifiedChapter?.chapterId === chapterId) {
@ -32,7 +34,7 @@ function Chapter(props: any) {
name: modifiedChapter.chapterName,
}
await updateChapter(chapterId, modifiedChapterCopy, session.data?.tokens?.access_token)
await mutate(`${getAPIUrl()}chapters/course/${props.course_uuid}/meta`)
await mutate(`${getAPIUrl()}chapters/course/${props.course_uuid}/meta?with_unpublished_activities=${withUnpublishedActivities}`)
await revalidateTags(['courses'], props.orgslug)
router.refresh()
}

View file

@ -33,7 +33,11 @@
"@stitches/react": "^1.2.8",
"@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.7",
"@tiptap/extension-bullet-list": "^2.11.7",
"@tiptap/extension-code-block-lowlight": "^2.11.7",
"@tiptap/extension-link": "^2.11.7",
"@tiptap/extension-list-item": "^2.11.7",
"@tiptap/extension-ordered-list": "^2.11.7",
"@tiptap/extension-table": "^2.11.7",
"@tiptap/extension-table-cell": "^2.11.7",
"@tiptap/extension-table-header": "^2.11.7",

View file

@ -78,9 +78,21 @@ importers:
'@tiptap/core':
specifier: ^2.11.7
version: 2.11.7(@tiptap/pm@2.11.7)
'@tiptap/extension-bullet-list':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
'@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-link':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
'@tiptap/extension-list-item':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
'@tiptap/extension-ordered-list':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))
'@tiptap/extension-table':
specifier: ^2.11.7
version: 2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)
@ -1490,6 +1502,12 @@ packages:
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/extension-link@2.11.7':
resolution: {integrity: sha512-qKIowE73aAUrnQCIifYP34xXOHOsZw46cT/LBDlb0T60knVfQoKVE4ku08fJzAV+s6zqgsaaZ4HVOXkQYLoW7g==}
peerDependencies:
'@tiptap/core': ^2.7.0
'@tiptap/pm': ^2.7.0
'@tiptap/extension-list-item@2.11.7':
resolution: {integrity: sha512-6ikh7Y+qAbkSuIHXPIINqfzmWs5uIGrylihdZ9adaIyvrN1KSnWIqrZIk/NcZTg5YFIJlXrnGSRSjb/QM3WUhw==}
peerDependencies:
@ -2646,6 +2664,9 @@ packages:
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
linkifyjs@4.2.0:
resolution: {integrity: sha512-pCj3PrQyATaoTYKHrgWRF3SJwsm61udVh+vuls/Rl6SptiDhgE7ziUIudAedRY9QEfynmM7/RmLEfPUyw1HPCw==}
load-script@1.0.0:
resolution: {integrity: sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==}
@ -3306,8 +3327,8 @@ packages:
tailwind-merge@2.6.0:
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
tailwind-merge@3.1.0:
resolution: {integrity: sha512-aV27Oj8B7U/tAOMhJsSGdWqelfmudnGMdXIlMnk1JfsjwSjts6o8HyfN7SFH3EztzH4YH8kk6GbLTHzITJO39Q==}
tailwind-merge@3.2.0:
resolution: {integrity: sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA==}
tailwindcss-animate@1.0.7:
resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==}
@ -4665,6 +4686,12 @@ snapshots:
dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
'@tiptap/extension-link@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))(@tiptap/pm@2.11.7)':
dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
'@tiptap/pm': 2.11.7
linkifyjs: 4.2.0
'@tiptap/extension-list-item@2.11.7(@tiptap/core@2.11.7(@tiptap/pm@2.11.7))':
dependencies:
'@tiptap/core': 2.11.7(@tiptap/pm@2.11.7)
@ -5280,7 +5307,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.1.0
tailwind-merge: 3.2.0
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
@ -6012,6 +6039,8 @@ snapshots:
dependencies:
uc.micro: 2.1.0
linkifyjs@4.2.0: {}
load-script@1.0.0: {}
locate-path@6.0.0:
@ -6772,7 +6801,7 @@ snapshots:
tailwind-merge@2.6.0: {}
tailwind-merge@3.1.0: {}
tailwind-merge@3.2.0: {}
tailwindcss-animate@1.0.7(tailwindcss@4.1.3):
dependencies:

View file

@ -39,6 +39,15 @@ export async function createFileActivity(
if (type === 'video') {
formData.append('name', data.name)
formData.append('video_file', file)
// Add video details
if (data.details) {
formData.append('details', JSON.stringify({
startTime: data.details.startTime || 0,
endTime: data.details.endTime || null,
autoplay: data.details.autoplay || false,
muted: data.details.muted || false
}))
}
endpoint = `${getAPIUrl()}activities/video`
} else if (type === 'documentpdf') {
formData.append('pdf_file', file)
@ -66,6 +75,23 @@ export async function createExternalVideoActivity(
data.chapter_id = chapter_id
data.activity_id = activity.id
// Add video details with null checking
const defaultDetails = {
startTime: 0,
endTime: null,
autoplay: false,
muted: false
}
const videoDetails = data.details ? {
startTime: data.details.startTime ?? defaultDetails.startTime,
endTime: data.details.endTime ?? defaultDetails.endTime,
autoplay: data.details.autoplay ?? defaultDetails.autoplay,
muted: data.details.muted ?? defaultDetails.muted
} : defaultDetails
data.details = JSON.stringify(videoDetails)
const result = await fetch(
`${getAPIUrl()}activities/external_video`,
RequestBodyWithAuthHeader('POST', data, null, access_token)

View file

@ -36,3 +36,17 @@ export async function markActivityAsComplete(
const res = await errorHandling(result)
return res
}
export async function unmarkActivityAsComplete(
org_slug: string,
course_uuid: string,
activity_uuid: string,
access_token: any
) {
const result: any = await fetch(
`${getAPIUrl()}trail/remove_activity/${activity_uuid}`,
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
)
const res = await errorHandling(result)
return res
}